Claude-skill-registry drawer-floating-system
Drawer and floating panel system for TMNL. Invoke when implementing drawer stacks, floating panels, drag/resize behaviors, or parallax animations. Provides Rolodex stack, stx-powered state, and container query patterns.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/drawer-floating-system" ~/.claude/skills/majiayu000-claude-skill-registry-drawer-floating-system && rm -rf "$T"
skills/data/drawer-floating-system/SKILL.mdDrawer & Floating Panel System for TMNL
Overview
A comprehensive panel management system with:
- Drawer Stack — Rolodex-style stacking with parallax lift animations
- Floating Panels — stx-powered state machines with 8-direction resize
- Global/Panel Slots — Composable content injection system
- Motion Blur — Velocity-based blur effects during drag
- Persistence — localStorage-backed panel geometry
Canonical Sources
TMNL Implementations
| File | Purpose | Pattern |
|---|---|---|
| Main drawer component | Compound component |
| Stack state management | React Context |
| Global content injection | Slot pattern |
| Per-drawer content slots | Slot pattern |
| Rolodex stack animation | GSAP driver |
| Parallax scroll effect | Transform math |
| Floating panel component | stx + resize |
| Legend-State + XState hybrid | stx pattern |
| Drag behavior HOC | Pointer events |
| 8-direction resize | Edge detection |
Testbeds
- DrawerTestbed:
— Rolodex stack, slot injection/testbed/drawer - FloatingPanelTestbed:
— Drag, resize, persistence/testbed/floating
Pattern 1: Drawer Stack Management — ROLODEX PATTERN
When: Managing multiple stacked drawers with animated transitions.
The drawer stack uses a LIFO (Last-In-First-Out) model with visual stacking:
import { createContext, useContext, useReducer } from 'react' interface DrawerState { drawers: DrawerInstance[] activeId: string | null } type DrawerAction = | { type: 'PUSH'; drawer: DrawerInstance } | { type: 'POP' } | { type: 'POP_TO'; id: string } | { type: 'REPLACE'; drawer: DrawerInstance } const drawerReducer = (state: DrawerState, action: DrawerAction): DrawerState => { switch (action.type) { case 'PUSH': return { drawers: [...state.drawers, action.drawer], activeId: action.drawer.id, } case 'POP': const popped = state.drawers.slice(0, -1) return { drawers: popped, activeId: popped[popped.length - 1]?.id ?? null, } case 'POP_TO': { const idx = state.drawers.findIndex(d => d.id === action.id) if (idx === -1) return state const remaining = state.drawers.slice(0, idx + 1) return { drawers: remaining, activeId: action.id, } } case 'REPLACE': { const replaced = [...state.drawers.slice(0, -1), action.drawer] return { drawers: replaced, activeId: action.drawer.id, } } } } const DrawerStackContext = createContext<{ state: DrawerState dispatch: React.Dispatch<DrawerAction> } | null>(null)
TMNL Location:
src/lib/drawer/DrawerStackContext.tsx
Pattern 2: Parallax Stack Animation — DEPTH EFFECTS
When: Creating visual depth with offset and scale transformations.
import { gsap } from 'gsap' interface ParallaxConfig { offsetY: number // Vertical offset per layer (px) scaleDecay: number // Scale reduction per layer (0-1) opacityDecay: number // Opacity reduction per layer (0-1) } const DEFAULT_CONFIG: ParallaxConfig = { offsetY: 20, scaleDecay: 0.02, opacityDecay: 0.15, } function applyParallaxStack( drawers: HTMLElement[], config: ParallaxConfig = DEFAULT_CONFIG ) { const active = drawers.length - 1 drawers.forEach((el, idx) => { const depth = active - idx // 0 = active, 1+ = behind gsap.to(el, { y: -depth * config.offsetY, scale: 1 - depth * config.scaleDecay, opacity: 1 - depth * config.opacityDecay, duration: 0.3, ease: 'power2.out', }) }) }
Key Formula:
transform: translateY(${-depth * offsetY}px) scale(${1 - depth * scaleDecay})
TMNL Location:
src/lib/drawer/animations/parallax-lift.ts
Pattern 3: TableService Integration — DATA-AWARE DRAWERS
When: Connecting drawers to data sources via table-service.
import { TableService } from '@/lib/table-service' interface DataDrawerProps<T> { tableService: TableService<T> rowId: string children: (data: T) => React.ReactNode } function DataDrawer<T>({ tableService, rowId, children }: DataDrawerProps<T>) { const row = useTableRow(tableService, rowId) if (!row) return null return ( <Drawer id={`data-${rowId}`} title={`Row ${rowId}`}> {children(row)} </Drawer> ) } // Usage <DataDrawer tableService={assetService} rowId="asset-123"> {(asset) => ( <div> <h2>{asset.name}</h2> <AssetDetails asset={asset} /> </div> )} </DataDrawer>
TMNL Location: Integration pattern documented in
src/lib/table-service/
Pattern 4: stx-Powered Floating Panels — HYBRID STATE
When: Managing complex panel state with Legend-State reactivity + XState machines.
The "stx" pattern combines:
- Legend-State — Fine-grained reactive state (position, size)
- XState — Lifecycle state machine (idle, dragging, resizing)
import { observable } from '@legendapp/state' import { setup, createActor } from 'xstate' // Legend-State for reactive data const panelState = observable({ panels: new Map<string, PanelState>(), zOrder: [] as string[], }) // XState for lifecycle const panelMachine = setup({ types: { context: {} as { panelId: string; initialPos: Position }, events: {} as | { type: 'DRAG_START'; x: number; y: number } | { type: 'DRAG_MOVE'; x: number; y: number } | { type: 'DRAG_END' } | { type: 'RESIZE_START'; edge: ResizeEdge } | { type: 'RESIZE_MOVE'; x: number; y: number } | { type: 'RESIZE_END' }, }, }).createMachine({ id: 'panel', initial: 'idle', states: { idle: { on: { DRAG_START: 'dragging', RESIZE_START: 'resizing', }, }, dragging: { on: { DRAG_MOVE: { actions: 'updatePosition' }, DRAG_END: 'idle', }, }, resizing: { on: { RESIZE_MOVE: { actions: 'updateSize' }, RESIZE_END: 'idle', }, }, }, }) // Combine into stx const getFloatingStx = () => ({ data: panelState, machine: panelMachine, computed: { topPanel: () => panelState.zOrder.get()[panelState.zOrder.get().length - 1], motionBlurStyle: (velocity: number) => ({ filter: `blur(${Math.min(velocity * 0.1, 4)}px)`, }), }, effects: { persist: () => { localStorage.setItem('panels', JSON.stringify(panelState.get())) }, restore: () => { const saved = localStorage.getItem('panels') if (saved) panelState.set(JSON.parse(saved)) }, }, })
TMNL Location:
src/lib/floating/floating-stx.ts
Pattern 5: FloatingPanel Component — 8-DIRECTION RESIZE
When: Creating resizable, draggable floating panels.
import { useFloatingPanel } from './hooks/useFloatingPanel' import { ResizeHandles } from './ResizeHandles' interface FloatingPanelProps { id: string title: string initialPosition?: Position initialSize?: Size minSize?: Size maxSize?: Size children: React.ReactNode } function FloatingPanel({ id, title, initialPosition = { x: 100, y: 100 }, initialSize = { width: 320, height: 240 }, minSize = { width: 200, height: 150 }, maxSize, children, }: FloatingPanelProps) { const { position, size, zIndex, isDragging, isResizing, handleDragStart, handleResizeStart, bringToFront, } = useFloatingPanel(id, { initialPosition, initialSize }) return ( <div className="floating-panel" style={{ position: 'fixed', left: position.x, top: position.y, width: size.width, height: size.height, zIndex, }} onPointerDown={bringToFront} > {/* Title bar (drag handle) */} <div className="panel-titlebar" onPointerDown={handleDragStart} > {title} </div> {/* Content */} <div className="panel-content"> {children} </div> {/* Resize handles */} <ResizeHandles onResizeStart={handleResizeStart} minSize={minSize} maxSize={maxSize} /> </div> ) }
TMNL Location:
src/lib/floating/FloatingPanel.tsx
Pattern 6: withDraggable HOC — POINTER EVENT MANAGEMENT
When: Adding drag behavior to any component.
import { useCallback, useRef, useState } from 'react' interface DraggableOptions { onDragStart?: (pos: Position) => void onDrag?: (pos: Position, delta: Position) => void onDragEnd?: (pos: Position) => void constraint?: 'none' | 'horizontal' | 'vertical' | DOMRect } function withDraggable<P extends object>( Component: React.ComponentType<P>, options: DraggableOptions = {} ) { return function DraggableComponent(props: P) { const [isDragging, setIsDragging] = useState(false) const startPos = useRef<Position>({ x: 0, y: 0 }) const currentPos = useRef<Position>({ x: 0, y: 0 }) const handlePointerDown = useCallback((e: React.PointerEvent) => { e.preventDefault() e.currentTarget.setPointerCapture(e.pointerId) startPos.current = { x: e.clientX, y: e.clientY } currentPos.current = { x: e.clientX, y: e.clientY } setIsDragging(true) options.onDragStart?.(startPos.current) }, []) const handlePointerMove = useCallback((e: React.PointerEvent) => { if (!isDragging) return const delta = { x: e.clientX - currentPos.current.x, y: e.clientY - currentPos.current.y, } currentPos.current = { x: e.clientX, y: e.clientY } options.onDrag?.(currentPos.current, delta) }, [isDragging]) const handlePointerUp = useCallback((e: React.PointerEvent) => { e.currentTarget.releasePointerCapture(e.pointerId) setIsDragging(false) options.onDragEnd?.(currentPos.current) }, []) return ( <Component {...props} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} data-dragging={isDragging} /> ) } }
TMNL Location:
src/lib/floating/withDraggable.tsx
Pattern 7: Container Queries — RESPONSIVE PANELS
When: Adapting panel content to available space.
// Use container queries for panel-intrinsic sizing function ResponsivePanelContent({ children }: { children: React.ReactNode }) { return ( <div className="@container panel-content"> <div className="@[200px]:grid-cols-1 @[400px]:grid-cols-2 @[600px]:grid-cols-3 grid gap-4"> {children} </div> </div> ) } // CSS (Tailwind container queries) // @container panel-content (min-width: 400px) { ... }
Alternative: FloatingDimensionContext
import { createContext, useContext } from 'react' const FloatingDimensionContext = createContext<Size>({ width: 320, height: 240 }) function FloatingPanel({ size, children }) { return ( <FloatingDimensionContext.Provider value={size}> {children} </FloatingDimensionContext.Provider> ) } function PanelContent() { const { width, height } = useContext(FloatingDimensionContext) // Adapt to available dimensions const columns = width < 300 ? 1 : width < 500 ? 2 : 3 return <Grid columns={columns}>...</Grid> }
TMNL Location:
src/lib/floating/FloatingDimensionContext.tsx
Pattern 8: Motion Blur — VELOCITY-BASED EFFECTS
When: Adding motion blur during fast drag operations.
const calculateMotionBlur = (velocity: number): string => { // velocity = pixels/frame const blurAmount = Math.min(velocity * 0.15, 8) // Cap at 8px const direction = velocity > 0 ? 'horizontal' : 'vertical' if (blurAmount < 0.5) return 'none' return `blur(${blurAmount}px)` } // In drag handler const handleDrag = (e: PointerEvent) => { const velocity = Math.sqrt( Math.pow(e.movementX, 2) + Math.pow(e.movementY, 2) ) panelRef.current.style.filter = calculateMotionBlur(velocity) } const handleDragEnd = () => { // Animate blur removal gsap.to(panelRef.current, { filter: 'blur(0px)', duration: 0.2, }) }
TMNL Location:
src/lib/floating/floating-stx.ts (computed)
Pattern 9: Panel Persistence — LOCALSTORAGE GEOMETRY
When: Remembering panel positions/sizes across sessions.
import { useEffect } from 'react' const STORAGE_KEY = 'tmnl:panel-geometry' interface PersistedPanel { id: string position: Position size: Size zIndex: number } const usePanelPersistence = (panelId: string) => { const loadGeometry = (): PersistedPanel | null => { const stored = localStorage.getItem(STORAGE_KEY) if (!stored) return null const panels: Record<string, PersistedPanel> = JSON.parse(stored) return panels[panelId] ?? null } const saveGeometry = (geometry: Omit<PersistedPanel, 'id'>) => { const stored = localStorage.getItem(STORAGE_KEY) const panels: Record<string, PersistedPanel> = stored ? JSON.parse(stored) : {} panels[panelId] = { id: panelId, ...geometry } localStorage.setItem(STORAGE_KEY, JSON.stringify(panels)) } // Debounced save during drag/resize const debouncedSave = useMemo( () => debounce(saveGeometry, 200), [panelId] ) return { loadGeometry, saveGeometry: debouncedSave } }
TMNL Location:
src/lib/floating/hooks/usePanelPersistence.ts
Pattern 10: Global vs Panel Slots — CONTENT INJECTION
When: Injecting content at different levels of the drawer hierarchy.
import { createContext, useContext } from 'react' // Global slot: content appears in all drawers const GlobalSlotContext = createContext<React.ReactNode>(null) function GlobalSlot({ children }: { children: React.ReactNode }) { return ( <GlobalSlotContext.Provider value={children}> {children} </GlobalSlotContext.Provider> ) } // Panel slot: content scoped to specific drawer const PanelSlotContext = createContext<Map<string, React.ReactNode>>(new Map()) function PanelSlot({ name, children, }: { name: string children: React.ReactNode }) { const slots = useContext(PanelSlotContext) slots.set(name, children) return null } // Usage in drawer function Drawer({ id, children }) { const globalContent = useContext(GlobalSlotContext) const [slots] = useState(() => new Map()) return ( <PanelSlotContext.Provider value={slots}> <div className="drawer"> {/* Global slot renders in header */} <div className="drawer-header">{globalContent}</div> {/* Main content */} <div className="drawer-content">{children}</div> {/* Named slots render in footer */} <div className="drawer-footer">{slots.get('footer')}</div> </div> </PanelSlotContext.Provider> ) } // Consumer <GlobalSlot> <Breadcrumbs /> </GlobalSlot> <Drawer id="details"> <AssetDetails /> <PanelSlot name="footer"> <ActionButtons /> </PanelSlot> </Drawer>
TMNL Location:
src/lib/drawer/GlobalSlot.tsx, src/lib/drawer/PanelSlot.tsx
Decision Tree: Component Selection
What are you building? │ ├─ Stacked content panels? │ ├─ Sequential navigation? → Drawer Stack (Pattern 1) │ └─ Depth effect? → Parallax Stack (Pattern 2) │ ├─ Floating window? │ ├─ Resizable + draggable? → FloatingPanel (Pattern 5) │ └─ Just draggable? → withDraggable HOC (Pattern 6) │ ├─ Complex state with animations? │ └─ stx-Powered Panels (Pattern 4) │ ├─ Remember panel positions? │ └─ Panel Persistence (Pattern 9) │ └─ Content injection? ├─ All drawers? → GlobalSlot └─ Specific drawer? → PanelSlot
Anti-Patterns
Don't: Use transform-origin incorrectly for stacking
// BANNED - Scale from wrong origin causes visual jump style={{ transform: `scale(${1 - depth * 0.02})` }} // CORRECT - Scale from top center for card-like effect style={{ transformOrigin: 'top center', transform: `scale(${1 - depth * 0.02})`, }}
Don't: Store panel position in useState during drag
// BANNED - Too many re-renders during drag const [position, setPosition] = useState({ x: 0, y: 0 }) onDrag={(pos) => setPosition(pos)} // 60+ re-renders/sec // CORRECT - Use refs for drag, sync state on end const posRef = useRef({ x: 0, y: 0 }) onDrag={(pos) => { posRef.current = pos }} onDragEnd={() => setPosition(posRef.current)}
Don't: Forget pointer capture
// BANNED - Drag breaks if pointer leaves element onPointerDown={() => setDragging(true)} // CORRECT - Capture pointer for reliable tracking onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId) setDragging(true) }}
Integration Points
Depends on:
— Panel lifecycle machinesxstate-integration
— Animation timingstmnl-animation-tokens
— withDraggable HOCreact-hoc-patterns
Used by:
— Detail panels for grid rowsag-grid-patterns
— Overlay systemtmnl-registry-patterns
Quick Reference
| Task | Pattern | File |
|---|---|---|
| Stack drawers with animation | Drawer Stack | drawer/DrawerStackContext.tsx |
| Create parallax depth | Parallax Stack | drawer/animations/parallax-lift.ts |
| Build floating panel | FloatingPanel | floating/FloatingPanel.tsx |
| Add drag behavior | withDraggable | floating/withDraggable.tsx |
| Manage stx state | floating-stx | floating/floating-stx.ts |
| Add resize handles | ResizeHandles | floating/ResizeHandles.tsx |
| Persist panel geometry | usePanelPersistence | floating/hooks/usePanelPersistence.ts |
| Inject global content | GlobalSlot | drawer/GlobalSlot.tsx |