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.
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