diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts
index 6426219e9..f990547b6 100644
--- a/libs/ui/components/src/index.ts
+++ b/libs/ui/components/src/index.ts
@@ -19,3 +19,4 @@ export * from './lib/recent-playlists/recent-playlists.component';
export * from './lib/video-player/sidebar/sidebar.component';
export * from './lib/video-player/toolbar/toolbar.component';
export * from './lib/vjs-player/vjs-player.component';
+export * from './lib/resizable/resizable.directive';
diff --git a/libs/ui/components/src/lib/resizable/resizable.directive.ts b/libs/ui/components/src/lib/resizable/resizable.directive.ts
new file mode 100644
index 000000000..058db07f9
--- /dev/null
+++ b/libs/ui/components/src/lib/resizable/resizable.directive.ts
@@ -0,0 +1,297 @@
+import {
+ Directive,
+ ElementRef,
+ Renderer2,
+ input,
+ output,
+ OnInit,
+ OnDestroy,
+ effect,
+ signal,
+ AfterViewInit,
+} from '@angular/core';
+
+/**
+ * ResizableDirective - Makes an element horizontally resizable by dragging its edge.
+ *
+ * Usage:
+ * ```html
+ *
+ * Content here
+ *
+ * ```
+ *
+ * Features:
+ * - Smooth drag handle with visual feedback
+ * - Persists width to localStorage
+ * - Respects min/max width constraints
+ * - Emits width changes for reactive updates
+ */
+@Directive({
+ selector: '[appResizable]',
+ standalone: true,
+})
+export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
+ /** Minimum width in pixels */
+ readonly minWidth = input(200);
+
+ /** Maximum width in pixels */
+ readonly maxWidth = input(600);
+
+ /** LocalStorage key for persisting width. If not set, width won't be persisted. */
+ readonly storageKey = input('');
+
+ /** Default width to use when no stored value exists */
+ readonly defaultWidth = input(400);
+
+ /** Position of the resize handle: 'right' or 'left' */
+ readonly handlePosition = input<'right' | 'left'>('right');
+
+ /** Emits when width changes during resize */
+ readonly widthChange = output();
+
+ /** Emits when resize starts */
+ readonly resizeStart = output();
+
+ /** Emits when resize ends */
+ readonly resizeEnd = output();
+
+ private handleElement: HTMLElement | null = null;
+ private isResizing = signal(false);
+ private startX = 0;
+ private startWidth = 0;
+ private currentWidth = signal(0);
+
+ // Event listener references for cleanup
+ private boundMouseMove: ((e: MouseEvent) => void) | null = null;
+ private boundMouseUp: ((e: MouseEvent) => void) | null = null;
+ private boundTouchMove: ((e: TouchEvent) => void) | null = null;
+ private boundTouchEnd: ((e: TouchEvent) => void) | null = null;
+
+ constructor(
+ private readonly el: ElementRef,
+ private readonly renderer: Renderer2
+ ) {
+ // Apply resizing class when dragging
+ effect(() => {
+ if (this.isResizing()) {
+ this.renderer.addClass(document.body, 'resizing-active');
+ this.renderer.addClass(this.el.nativeElement, 'is-resizing');
+ } else {
+ this.renderer.removeClass(document.body, 'resizing-active');
+ this.renderer.removeClass(this.el.nativeElement, 'is-resizing');
+ }
+ });
+ }
+
+ ngOnInit(): void {
+ // Load persisted width FIRST to avoid flash of default width
+ this.loadPersistedWidth();
+ this.setupStyles();
+ }
+
+ ngAfterViewInit(): void {
+ this.createResizeHandle();
+ }
+
+ ngOnDestroy(): void {
+ this.removeGlobalListeners();
+ if (this.handleElement) {
+ this.handleElement.remove();
+ }
+ }
+
+ private setupStyles(): void {
+ const el = this.el.nativeElement;
+
+ // Ensure the element has position relative for handle positioning
+ const computedStyle = window.getComputedStyle(el);
+ if (computedStyle.position === 'static') {
+ this.renderer.setStyle(el, 'position', 'relative');
+ }
+
+ // Add transition for smooth width changes (only when not dragging)
+ this.renderer.addClass(el, 'resizable-element');
+ }
+
+ private createResizeHandle(): void {
+ const handle = this.renderer.createElement('div');
+ this.handleElement = handle;
+
+ // Set up handle classes based on position
+ this.renderer.addClass(handle, 'resize-handle');
+ this.renderer.addClass(
+ handle,
+ `resize-handle--${this.handlePosition()}`
+ );
+
+ // Create inner visual elements for the handle
+ const handleLine = this.renderer.createElement('div');
+ this.renderer.addClass(handleLine, 'resize-handle__line');
+ this.renderer.appendChild(handle, handleLine);
+
+ // Create grip dots
+ const gripDots = this.renderer.createElement('div');
+ this.renderer.addClass(gripDots, 'resize-handle__grip');
+ for (let i = 0; i < 3; i++) {
+ const dot = this.renderer.createElement('span');
+ this.renderer.appendChild(gripDots, dot);
+ }
+ this.renderer.appendChild(handle, gripDots);
+
+ // Mouse events
+ this.renderer.listen(handle, 'mousedown', (e: MouseEvent) =>
+ this.onMouseDown(e)
+ );
+
+ // Touch events for mobile support
+ this.renderer.listen(handle, 'touchstart', (e: TouchEvent) =>
+ this.onTouchStart(e)
+ );
+
+ // Append handle to element
+ this.renderer.appendChild(this.el.nativeElement, handle);
+ }
+
+ private loadPersistedWidth(): void {
+ const key = this.storageKey();
+ let widthToApply = this.defaultWidth();
+
+ if (key) {
+ const stored = localStorage.getItem(key);
+ if (stored) {
+ const width = parseInt(stored, 10);
+ if (!isNaN(width)) {
+ widthToApply = this.clampWidth(width);
+ }
+ }
+ }
+
+ // Apply width immediately without transition to avoid flash
+ this.setWidthImmediate(widthToApply);
+ }
+
+ /** Set width immediately without CSS transition */
+ private setWidthImmediate(width: number): void {
+ this.currentWidth.set(width);
+ // Temporarily disable transition for immediate application
+ this.renderer.setStyle(this.el.nativeElement, 'transition', 'none');
+ this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`);
+
+ // Re-enable transition after a frame
+ requestAnimationFrame(() => {
+ this.renderer.removeStyle(this.el.nativeElement, 'transition');
+ });
+ }
+
+ private onMouseDown(e: MouseEvent): void {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.startResize(e.clientX);
+
+ this.boundMouseMove = (event: MouseEvent) => this.onMouseMove(event);
+ this.boundMouseUp = (event: MouseEvent) => this.onMouseUp(event);
+
+ document.addEventListener('mousemove', this.boundMouseMove);
+ document.addEventListener('mouseup', this.boundMouseUp);
+ }
+
+ private onTouchStart(e: TouchEvent): void {
+ if (e.touches.length !== 1) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.startResize(e.touches[0].clientX);
+
+ this.boundTouchMove = (event: TouchEvent) => this.onTouchMove(event);
+ this.boundTouchEnd = (event: TouchEvent) => this.onTouchEnd(event);
+
+ document.addEventListener('touchmove', this.boundTouchMove, {
+ passive: false,
+ });
+ document.addEventListener('touchend', this.boundTouchEnd);
+ }
+
+ private startResize(clientX: number): void {
+ this.isResizing.set(true);
+ this.startX = clientX;
+ this.startWidth = this.el.nativeElement.offsetWidth;
+ this.resizeStart.emit();
+ }
+
+ private onMouseMove(e: MouseEvent): void {
+ if (!this.isResizing()) return;
+ this.resize(e.clientX);
+ }
+
+ private onTouchMove(e: TouchEvent): void {
+ if (!this.isResizing() || e.touches.length !== 1) return;
+ e.preventDefault();
+ this.resize(e.touches[0].clientX);
+ }
+
+ private resize(clientX: number): void {
+ const delta =
+ this.handlePosition() === 'right'
+ ? clientX - this.startX
+ : this.startX - clientX;
+
+ const newWidth = this.clampWidth(this.startWidth + delta);
+ this.setWidth(newWidth);
+ this.widthChange.emit(newWidth);
+ }
+
+ private onMouseUp(_e: MouseEvent): void {
+ this.endResize();
+ }
+
+ private onTouchEnd(_e: TouchEvent): void {
+ this.endResize();
+ }
+
+ private endResize(): void {
+ if (!this.isResizing()) return;
+
+ this.isResizing.set(false);
+ this.removeGlobalListeners();
+ this.persistWidth();
+ this.resizeEnd.emit(this.currentWidth());
+ }
+
+ private removeGlobalListeners(): void {
+ if (this.boundMouseMove) {
+ document.removeEventListener('mousemove', this.boundMouseMove);
+ this.boundMouseMove = null;
+ }
+ if (this.boundMouseUp) {
+ document.removeEventListener('mouseup', this.boundMouseUp);
+ this.boundMouseUp = null;
+ }
+ if (this.boundTouchMove) {
+ document.removeEventListener('touchmove', this.boundTouchMove);
+ this.boundTouchMove = null;
+ }
+ if (this.boundTouchEnd) {
+ document.removeEventListener('touchend', this.boundTouchEnd);
+ this.boundTouchEnd = null;
+ }
+ }
+
+ private clampWidth(width: number): number {
+ return Math.max(this.minWidth(), Math.min(this.maxWidth(), width));
+ }
+
+ private setWidth(width: number): void {
+ this.currentWidth.set(width);
+ this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`);
+ }
+
+ private persistWidth(): void {
+ const key = this.storageKey();
+ if (key) {
+ localStorage.setItem(key, this.currentWidth().toString());
+ }
+ }
+}
diff --git a/libs/ui/components/src/lib/resizable/resizable.scss b/libs/ui/components/src/lib/resizable/resizable.scss
new file mode 100644
index 000000000..1ae5be5a0
--- /dev/null
+++ b/libs/ui/components/src/lib/resizable/resizable.scss
@@ -0,0 +1,184 @@
+/**
+ * Resizable Directive Styles
+ *
+ * A refined, subtle resize handle that provides clear affordance
+ * without being visually intrusive. Uses a vertical grip pattern
+ * that becomes more prominent on hover/active states.
+ */
+
+// Prevent text selection during resize
+body.resizing-active {
+ user-select: none !important;
+ cursor: col-resize !important;
+
+ * {
+ cursor: col-resize !important;
+ }
+}
+
+// Base element styles
+.resizable-element {
+ // Smooth transition when not actively resizing
+ &:not(.is-resizing) {
+ transition: width 0.15s ease-out;
+ }
+
+ &.is-resizing {
+ transition: none;
+ }
+}
+
+// Resize handle base styles
+.resize-handle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 12px;
+ z-index: 100;
+ cursor: col-resize;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ // Visual feedback on hover/active
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 4px;
+ background: transparent;
+ transition: background-color 0.2s ease;
+ border-radius: 2px;
+ }
+
+ &:hover::before,
+ &:active::before {
+ background: rgba(255, 255, 255, 0.08);
+ }
+
+ &:active::before {
+ background: rgba(255, 255, 255, 0.12);
+ }
+
+ // Position variants
+ &--right {
+ right: -6px;
+
+ &::before {
+ left: 50%;
+ transform: translateX(-50%);
+ }
+ }
+
+ &--left {
+ left: -6px;
+
+ &::before {
+ right: 50%;
+ transform: translateX(50%);
+ }
+ }
+
+ // The main visible line
+ &__line {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background: rgba(255, 255, 255, 0.12);
+ transition: all 0.2s ease;
+ }
+
+ &:hover &__line {
+ width: 2px;
+ background: rgba(255, 255, 255, 0.25);
+ box-shadow: 0 0 8px rgba(255, 255, 255, 0.1);
+ }
+
+ &:active &__line {
+ width: 2px;
+ background: var(--mdc-theme-primary, #6366f1);
+ box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
+ }
+
+ // Grip dots indicator
+ &__grip {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ z-index: 1;
+
+ span {
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.4);
+ transition: all 0.15s ease;
+ }
+ }
+
+ &:hover &__grip {
+ opacity: 1;
+
+ span {
+ background: rgba(255, 255, 255, 0.6);
+ }
+ }
+
+ &:active &__grip {
+ opacity: 1;
+
+ span {
+ background: var(--mdc-theme-primary, #6366f1);
+ transform: scale(1.2);
+ }
+ }
+}
+
+// Light theme adjustments (if needed)
+.light-theme {
+ .resize-handle {
+ &::before {
+ background: transparent;
+ }
+
+ &:hover::before,
+ &:active::before {
+ background: rgba(0, 0, 0, 0.04);
+ }
+
+ &:active::before {
+ background: rgba(0, 0, 0, 0.08);
+ }
+
+ &__line {
+ background: rgba(0, 0, 0, 0.12);
+ }
+
+ &:hover &__line {
+ background: rgba(0, 0, 0, 0.25);
+ box-shadow: 0 0 8px rgba(0, 0, 0, 0.05);
+ }
+
+ &:active &__line {
+ background: var(--mdc-theme-primary, #6366f1);
+ box-shadow: 0 0 12px rgba(99, 102, 241, 0.2);
+ }
+
+ &__grip {
+ span {
+ background: rgba(0, 0, 0, 0.3);
+ }
+ }
+
+ &:hover &__grip {
+ span {
+ background: rgba(0, 0, 0, 0.5);
+ }
+ }
+ }
+}