PanelResizer

ui layoutinteraction

Drag-Griff auf einer vertikalen Spaltengrenze plus useColumnWidth-Hook für persistente, geklemmte Breiten (LocalStorage, CSS-Variable). Magnetisches Einrasten auf die Standardbreite, Doppelklick = Reset.

Seitenspalte · 180px
Ziehen an der Trennlinie · Doppelklick = Reset

Abhängigkeiten

Barrierefreiheit

  • role="separator", aria-orientation="vertical", aria-label.

Beispiele

Resizbare Spalte
const col = useColumnWidth({ storageKey: 'mp.left', cssVar: '--left-w', defaultWidth: 320 });
<div style={col.style} className="grid">
  …
  <PanelResizer edge="left" offsetVar="--left-w" width={col.width}
    defaultWidth={col.defaultWidth} onResize={col.commit} onReset={col.reset} />
</div>

Genutzte Tokens · 3

--accent--ease-standard--motion-fast

Quelldateien

components/ui/PanelResizer.tsx tsx
import {
  useCallback,
  useState,
  type CSSProperties,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import styles from './PanelResizer.module.css';

interface UseColumnWidthOptions {
  /** LocalStorage-Key — eindeutig pro Trennlinie. Gleiche Keys teilen sich
   *  die Breite (z.B. alle Hauptansichten). */
  storageKey: string;
  /** Name der CSS-Variable, die als `style` an den Grid-Container gehängt wird
   *  (z.B. `--shell-left-width`). Mehrere Spalten in einem Grid nutzen
   *  unterschiedliche Vars und werden gemerged. */
  cssVar: string;
  defaultWidth: number;
  min?: number;
  max?: number;
}

const DEFAULT_MIN = 240;
const DEFAULT_MAX = 800;

function readStored(storageKey: string, fallback: number, min: number, max: number): number {
  if (typeof window === 'undefined') return fallback;
  const raw = window.localStorage.getItem(storageKey);
  // `Number(null)` ist 0 (finite) — fehlender Key muss daher als Fallback gelten,
  // sonst klemmte die erste Anzeige fälschlich auf `min` statt `defaultWidth`.
  if (raw === null) return fallback;
  const n = Number(raw);
  if (!Number.isFinite(n)) return fallback;
  return Math.max(min, Math.min(max, n));
}

/** Persistente, klemmungs-sichere Spaltenbreite für ein resizbares Panel.
 *  Liest beim Mount aus LocalStorage, schreibt synchron beim Commit und liefert
 *  eine `{ [cssVar]: '<px>' }`-Style-Map, die direkt (ggf. gemerged mit weiteren)
 *  als `style` an den Grid-Container weitergereicht wird. `reset()` stellt die
 *  Standardbreite wieder her. */
export function useColumnWidth(options: UseColumnWidthOptions): {
  width: number;
  defaultWidth: number;
  commit: (next: number) => void;
  reset: () => void;
  style: CSSProperties;
} {
  const { storageKey, cssVar, defaultWidth, min = DEFAULT_MIN, max = DEFAULT_MAX } = options;
  const [width, setWidth] = useState<number>(() =>
    readStored(storageKey, defaultWidth, min, max),
  );
  const commit = useCallback(
    (next: number) => {
      const clamped = Math.max(min, Math.min(max, next));
      setWidth(clamped);
      try {
        window.localStorage.setItem(storageKey, String(clamped));
      } catch {
        // Storage voll oder gesperrt — Breite bleibt rein in-memory.
      }
    },
    [storageKey, min, max],
  );
  const reset = useCallback(() => commit(defaultWidth), [commit, defaultWidth]);
  const style = { [cssVar]: `${width}px` } as CSSProperties;
  return { width, defaultWidth, commit, reset, style };
}

/** Distanz (px), in der die Trennlinie magnetisch auf die Standardbreite
 *  einrastet. */
const SNAP_PX = 12;

interface PanelResizerProps {
  /** `'left'` = resizt die linke Spalte (Trennlinie rechts von ihr),
   *  `'right'` = resizt die rechte Spalte (Trennlinie links von ihr). */
  edge: 'left' | 'right';
  /** Dieselbe CSS-Variable wie im zugehörigen `useColumnWidth` — positioniert
   *  den Griff exakt auf der Spaltengrenze. */
  offsetVar: string;
  width: number;
  defaultWidth: number;
  onResize: (next: number) => void;
  onReset: () => void;
  label?: string;
}

/** Drag-Griff auf einer vertikalen Spaltengrenze. Wird als Kind des (relativ
 *  positionierten, nicht clippenden) Grid-Containers gerendert und sitzt per
 *  `offsetVar` mittig auf der Trennlinie — unabhängig davon, ob die angrenzenden
 *  Panels scrollen/clippen. Ziehen passt die Breite an; nahe der Standardbreite
 *  rastet es ein, Doppelklick setzt zurück. */
export function PanelResizer({
  edge,
  offsetVar,
  width,
  defaultWidth,
  onResize,
  onReset,
  label,
}: PanelResizerProps) {
  const [active, setActive] = useState(false);
  const [snapped, setSnapped] = useState(false);

  function onPointerDown(e: ReactPointerEvent) {
    e.preventDefault();
    const startX = e.clientX;
    const startWidth = width;
    setActive(true);
    document.body.style.cursor = 'col-resize';
    document.body.style.userSelect = 'none';
    const onMove = (ev: PointerEvent) => {
      const delta = edge === 'left' ? ev.clientX - startX : startX - ev.clientX;
      let next = startWidth + delta;
      const isSnap = Math.abs(next - defaultWidth) <= SNAP_PX;
      if (isSnap) next = defaultWidth;
      setSnapped(isSnap);
      onResize(next);
    };
    const onUp = () => {
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
      document.body.style.cursor = '';
      document.body.style.userSelect = '';
      setActive(false);
      setSnapped(false);
    };
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
  }

  const position: CSSProperties =
    edge === 'left'
      ? { left: `calc(var(${offsetVar}) - 3px)` }
      : { right: `calc(var(${offsetVar}) - 3px)` };

  const className = [
    styles.resizer,
    active ? styles.resizerActive : '',
    snapped ? styles.resizerSnapped : '',
  ]
    .filter(Boolean)
    .join(' ');

  return (
    <div
      className={className}
      style={position}
      onPointerDown={onPointerDown}
      onDoubleClick={onReset}
      role="separator"
      aria-orientation="vertical"
      aria-label={label ?? 'Spaltenbreite ändern'}
      title="Ziehen zum Anpassen · Doppelklick setzt zurück"
    />
  );
}
components/ui/PanelResizer.module.css css
/* Drag-Griff auf einer Spaltengrenze. Wird als Kind des Grid-Containers
 * gerendert und per Inline-Style (left/right: calc(var(...) - 3px)) mittig auf
 * die Trennlinie gesetzt. 6px breites Hit-Target, straddelt die 1px-Border.
 * Aktiv/Hover deutlicher, eingerastet voll in Accent-Farbe. */
.resizer {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 6px;
  cursor: col-resize;
  z-index: 5;
  background: transparent;
  transition: background-color var(--motion-fast) var(--ease-standard);
}
.resizer:hover,
.resizerActive {
  background: color-mix(in oklab, var(--accent), transparent 50%);
}
/* Eingerastet auf Standardbreite — volles Accent als Feedback. */
.resizerSnapped,
.resizerSnapped:hover {
  background: var(--accent);
}

Holen

# MCP (Claude Code / Cursor)
get_component({ name: "panel-resizer" })

# Registry direkt
https://www.ekklesi.tools/design/registry/components/panel-resizer.json