Drawer für React
Eine Schublade, die man ziehen kann. Von jeder Kante, Bottom Sheets mit Griff und Rastpunkten, nicht modal neben einer bedienbaren Seite, in einem Rahmen und verschachtelt, wobei die äußere zurücktritt.
Flächen, die dem Finger folgen
Eine Schublade gleitet von einer Kante herein und lässt sich zurückziehen, mehr als ein Sheet, das nur öffnet und schließt. Modal sitzt sie auf dem nativen Dialog mit Fokusfalle, inaktiver Seite, Escape, Abdunklung, gesperrtem Scrollen und Fokus zurück zum Knopf, und Telefone können eine andere Seite bekommen als der Desktop.
„Vier Kanten“ bringt Menü von links, Details von rechts, Suche von oben und Teilen von unten, jede folgt einem Wisch zurück zu ihrer Kante. „Bottom Sheet mit Rastpunkten“ ruht über einer Karte auf drei Höhen, ein schneller Wurf trägt es zum nächsten Punkt und die Karte bleibt bedienbar. „Nicht modale Details“ öffnet neben einer Liste, die bedienbar bleibt, ohne Abdunklung und ohne Fokusfalle, eine andere Zeile aktualisiert die Schublade direkt, am Telefon kommt sie von unten. „Verschachtelte Filter“ öffnet alle Marken in einer zweiten Schublade über der Filter-Schublade, die erste tritt zurück, bis sie schließt.
Nimm eine nicht modale Schublade, wenn Menschen Liste und Detail vergleichen. Lass den Griff am Bottom Sheet sichtbar, er sagt, dass sich die Fläche bewegen lässt. Verschachtle höchstens eine Ebene, eine dritte Schublade zeigt, dass der Inhalt eine eigene Seite will.
Variants
Details
Was Schublade mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Overlays
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/drawer.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 5 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle DrawerProps aus components/ui/drawer.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
triggeroptionalReact.ReactElement | nullThe button that opens it
openoptionalbooleandefaultOpenoptionalbooleanonOpenChangeoptional(open: boolean) => voidsideoptionalDrawerSideleftrighttopbottom
mobileSideoptionalDrawerSideleftrighttopbottom
The side on phones below 640 px, e.g. bottom for a side drawer
modaloptionalbooleanModal: focus stays inside, the page is inert and dimmed. Off: the page stays usable next to the drawer.
containedoptionalbooleanLives inside its positioned parent instead of the window (a phone frame, a panel, a map)
snapPointsoptionalnumber[]Heights for a top or bottom drawer as parts of the available height, e.g. [0.25, 0.55, 0.92]
defaultSnapoptionalnumberIndex of the snap point it opens at
onSnapChangeoptional(index: number) => voiddismissibleoptionalbooleanSwipe toward the edge, Escape and the scrim close it
sizeoptional'sm' | 'md' | 'lg'Width of a side drawer
floatingoptionalbooleanInset from the edges with all corners rounded
titlePflichtReact.ReactNodedescriptionoptionalReact.ReactNodemetaoptionalReact.ReactNodeSomething next to the title, e.g. a count
footeroptionalReact.ReactNode | ((close: () => void) => React.ReactNode)hideCloseoptionalbooleanchildrenoptionalReact.ReactNode
Eingebaute Texte
Diese Texte schreibt Schublade selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 5 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| close | Englisch Close | Deutsch Schließen |
| handle | Englisch Resize the drawer | Deutsch Höhe ändern |
| handleHint | Englisch Arrow up and down change the height, Escape closes. | Deutsch Pfeil hoch und runter ändern die Höhe, Escape schließt. |
| height | Englisch Drawer at {p} | Deutsch Schublade bei {p} |
| edge | Englisch Drag to close | Deutsch Zum Schließen ziehen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Schublade reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pos1, Ende, Tab und Leertaste.
- Baut auf dem nativen dialog-Element auf. Der Browser hält den Fokus im Fenster und schließt es mit Escape.
- Setzt aria-describedby, aria-label, aria-labelledby, aria-live und aria-modal, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle native.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 476 Zeilen
'use client';
import { cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn, formatPercent, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const TEXTS = {
en: { close: 'Close', handle: 'Resize the drawer', handleHint: 'Arrow up and down change the height, Escape closes.', height: 'Drawer at {p}', edge: 'Drag to close' },
de: { close: 'Schließen', handle: 'Höhe ändern', handleHint: 'Pfeil hoch und runter ändern die Höhe, Escape schließt.', height: 'Schublade bei {p}', edge: 'Zum Schließen ziehen' },
};
export type DrawerSide = 'left' | 'right' | 'top' | 'bottom';
export interface DrawerProps {
/** The button that opens it */
trigger?: React.ReactElement | null;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
side?: DrawerSide;
/** The side on phones below 640 px, e.g. bottom for a side drawer */
mobileSide?: DrawerSide;
/** Modal: focus stays inside, the page is inert and dimmed. Off: the page stays usable next to the drawer. */
modal?: boolean;
/** Lives inside its positioned parent instead of the window (a phone frame, a panel, a map) */
contained?: boolean;
/** Heights for a top or bottom drawer as parts of the available height, e.g. [0.25, 0.55, 0.92] */
snapPoints?: number[];
/** Index of the snap point it opens at */
defaultSnap?: number;
onSnapChange?: (index: number) => void;
/** Swipe toward the edge, Escape and the scrim close it */
dismissible?: boolean;
/** Width of a side drawer */
size?: 'sm' | 'md' | 'lg';
/** Inset from the edges with all corners rounded */
floating?: boolean;
title: React.ReactNode;
description?: React.ReactNode;
/** Something next to the title, e.g. a count */
meta?: React.ReactNode;
footer?: React.ReactNode | ((close: () => void) => React.ReactNode);
hideClose?: boolean;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
children?: React.ReactNode;
}
/* A drawer opened from inside another one pushes its parent back a little */
const ParentContext = createContext<((open: boolean) => void) | null>(null);
let locks = 0;
function lockScroll() {
const html = document.documentElement;
if (locks++ === 0) {
const gutter = window.innerWidth - html.clientWidth;
html.dataset.drawerOverflow = html.style.overflow;
html.style.overflow = 'hidden';
if (gutter) html.style.paddingRight = `${gutter}px`;
}
return () => {
if (--locks === 0) {
html.style.overflow = html.dataset.drawerOverflow ?? '';
html.style.paddingRight = '';
delete html.dataset.drawerOverflow;
}
};
}
const WIDTH = { sm: 'w-[min(20rem,calc(100%-2.5rem))]', md: 'w-[min(26rem,calc(100%-2.5rem))]', lg: 'w-[min(36rem,calc(100%-2.5rem))]' };
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
const EASE = 'cubic-bezier(0.2, 0.8, 0.2, 1)';
/**
* A drawer from any edge you can drag. Bottom and top drawers rest on snap points and follow a
* swipe, side drawers close with a swipe toward their edge. Modal on the native dialog (focus
* trap, inert page, Escape, scrim, scroll lock, focus back), or non-modal next to a usable page,
* or contained in a frame. Drawers nest, the parent steps back.
* <Drawer side="bottom" snapPoints={[0.3, 0.6, 0.92]} trigger={<Button>Filters</Button>} title="Filters">…</Drawer>
*/
export function Drawer({
trigger,
open: openProp,
defaultOpen,
onOpenChange,
side: sideProp = 'right',
mobileSide,
modal = true,
contained,
snapPoints,
defaultSnap,
onSnapChange,
dismissible = true,
size = 'md',
floating,
title,
description,
meta,
footer,
hideClose,
labels,
className,
children,
}: DrawerProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const id = useId();
const [inner, setInner] = useState(Boolean(defaultOpen));
const open = openProp ?? inner;
const setOpen = useCallback(
(v: boolean) => {
setInner(v);
onOpenChange?.(v);
},
[onOpenChange],
);
const close = useCallback(() => setOpen(false), [setOpen]);
// The side follows the screen: a side drawer can become a bottom drawer on phones
const [phone, setPhone] = useState(false);
useEffect(() => {
if (!mobileSide) return;
const mq = window.matchMedia('(max-width: 639px)');
const on = () => setPhone(mq.matches);
on();
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, [mobileSide]);
const side = phone && mobileSide ? mobileSide : sideProp;
const vertical = side === 'top' || side === 'bottom';
const snaps = vertical && snapPoints?.length ? [...snapPoints].sort((a, b) => a - b) : null;
const [snap, setSnapState] = useState(Math.min(defaultSnap ?? (snaps ? snaps.length - 1 : 0), (snaps?.length ?? 1) - 1));
const setSnap = (i: number) => {
setSnapState(i);
onSnapChange?.(i);
};
/* ---------- presence and motion ---------- */
// present: in the DOM. shown: at rest on screen (false while it slides in or out).
const [present, setPresent] = useState(open);
const [shown, setShown] = useState(open);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (open) {
setPresent(true);
return;
}
setShown(false);
if (prefersReducedMotion()) {
setPresent(false);
return;
}
const timer = window.setTimeout(() => setPresent(false), 340);
return () => window.clearTimeout(timer);
}, [open]);
const dialogRef = useRef<HTMLDialogElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const returnTo = useRef<HTMLElement | null>(null);
const native = modal && !contained;
useIsoLayoutEffect(() => {
if (!present || !open) return;
if (!returnTo.current) returnTo.current = document.activeElement as HTMLElement;
const d = dialogRef.current;
let unlock: (() => void) | undefined;
if (native && d && !d.open) {
d.showModal();
unlock = lockScroll();
}
// Slide in on the next frame, so the start position paints first
const raf = requestAnimationFrame(() => requestAnimationFrame(() => setShown(true)));
if (!native && !defaultOpen) {
// A non-modal drawer takes focus once it opens, the page around stays usable
window.setTimeout(() => (panelRef.current?.querySelector<HTMLElement>('[data-drawer-title]') ?? panelRef.current)?.focus({ preventScroll: true }), 40);
}
return () => {
cancelAnimationFrame(raf);
unlock?.();
};
}, [present, open, native]);
useEffect(() => {
if (present) return;
if (dialogRef.current?.open) dialogRef.current.close();
const back = returnTo.current;
returnTo.current = null;
if (back && document.contains(back) && (!document.activeElement || document.activeElement === document.body)) back.focus({ preventScroll: true });
}, [present]);
// Tell the parent drawer that a child is open
const parent = useContext(ParentContext);
useEffect(() => {
if (!parent) return;
parent(open);
return () => parent(false);
}, [open, parent]);
const [childOpen, setChildOpen] = useState(false);
/* ---------- Escape and focus for drawers without a native dialog ---------- */
useEffect(() => {
if (native || !open || !present) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || childOpen || !dismissible) return;
// Non-modal: only while focus is inside, so Escape elsewhere on the page is left alone
if (!modal && !panelRef.current?.contains(document.activeElement)) return;
e.preventDefault();
close();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [native, open, present, modal, childOpen, dismissible, close]);
const trapTab = (e: React.KeyboardEvent) => {
if (e.key !== 'Tab' || !modal || native) return;
const list = Array.from(panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []).filter((el) => el.offsetParent !== null);
if (!list.length) return;
const first = list[0];
const last = list[list.length - 1];
if (e.shiftKey && document.activeElement === first) (e.preventDefault(), last.focus());
else if (!e.shiftKey && document.activeElement === last) (e.preventDefault(), first.focus());
};
/* ---------- dragging ---------- */
const [drag, setDrag] = useState(0);
const dragging = useRef<{ start: number; last: number; time: number; v: number; size: number } | null>(null);
const axis = (e: React.PointerEvent) => (vertical ? e.clientY : e.clientX);
// Positive drag moves toward the edge the drawer belongs to
const sign = side === 'bottom' || side === 'right' ? 1 : -1;
const onPointerDown = (e: React.PointerEvent<HTMLElement>) => {
if (e.button !== 0 || (e.target as HTMLElement).closest('button:not([data-drawer-handle]),a,input,select,textarea')) return;
const box = panelRef.current?.getBoundingClientRect();
dragging.current = { start: axis(e), last: axis(e), time: performance.now(), v: 0, size: vertical ? box?.height ?? 1 : box?.width ?? 1 };
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent) => {
const d = dragging.current;
if (!d) return;
const now = performance.now();
const pos = axis(e);
d.v = ((pos - d.last) / Math.max(1, now - d.time)) * sign;
d.last = pos;
d.time = now;
let delta = (pos - d.start) * sign;
// Past the top snap the drawer resists, it does not fly off
if (delta < 0) delta = snaps && snap < snaps.length - 1 ? delta : -Math.sqrt(-delta) * 2.2;
setDrag(delta);
};
const onPointerUp = () => {
const d = dragging.current;
if (!d) return;
dragging.current = null;
const delta = drag;
setDrag(0);
if (snaps) {
const H = availableHeight();
const current = snaps[snap] * H - delta;
// Throw: the speed carries it a little further
const projected = current - d.v * 180;
if (dismissible && projected < snaps[0] * H * 0.55) return close();
let best = 0;
snaps.forEach((s, i) => Math.abs(s * H - projected) < Math.abs(snaps[best] * H - projected) && (best = i));
setSnap(best);
return;
}
if (dismissible && (delta > d.size * 0.35 || (d.v > 0.6 && delta > 24))) close();
};
const dragProps = { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp };
const containerRef = useRef<HTMLDivElement>(null);
const availableHeight = () => (contained ? containerRef.current?.clientHeight ?? 600 : window.innerHeight);
/* ---------- handle keys ---------- */
const onHandleKey = (e: React.KeyboardEvent) => {
if (!snaps) return;
const up = side === 'bottom' ? 'ArrowUp' : 'ArrowDown';
const down = side === 'bottom' ? 'ArrowDown' : 'ArrowUp';
if (e.key === up) (e.preventDefault(), setSnap(Math.min(snaps.length - 1, snap + 1)));
else if (e.key === down) {
e.preventDefault();
if (snap === 0 && dismissible) close();
else setSnap(Math.max(0, snap - 1));
} else if (e.key === 'Home') (e.preventDefault(), setSnap(snaps.length - 1));
else if (e.key === 'End') (e.preventDefault(), setSnap(0));
};
/* ---------- geometry ---------- */
const maxSnap = snaps ? snaps[snaps.length - 1] : 1;
const restOffset = snaps ? `${((maxSnap - snaps[snap]) / maxSnap) * 100}%` : '0%';
// Out of sight: the full size, plus the inset and the shadow of a floating drawer
const hidden = floating ? 'calc(100% + 32px)' : 'calc(100% + 16px)';
const offset = shown ? restOffset : hidden;
const move = vertical ? 'translateY' : 'translateX';
const s = side === 'bottom' || side === 'right' ? 1 : -1;
const transform = `${move}(calc(${s} * (${offset} + ${Math.round(drag)}px)))`;
const transition = dragging.current ? 'none' : `transform ${shown ? 480 : 300}ms ${shown ? EASE : 'cubic-bezier(0.4,0,0.9,0.6)'}`;
const inset = floating ? 'm-2' : '';
const place: Record<DrawerSide, string> = {
right: cn('inset-y-0 end-0 h-full', WIDTH[size]),
left: cn('inset-y-0 start-0 h-full', WIDTH[size]),
bottom: cn('inset-x-0 bottom-0 mx-auto w-full max-w-[48rem]', !snaps && 'max-h-[88%]'),
top: cn('inset-x-0 top-0 mx-auto w-full max-w-[48rem]', !snaps && 'max-h-[88%]'),
};
const round: Record<DrawerSide, string> = {
right: floating ? 'rounded-2xl' : 'rounded-s-2xl',
left: floating ? 'rounded-2xl' : 'rounded-e-2xl',
bottom: floating ? 'rounded-2xl' : 'rounded-t-2xl',
top: floating ? 'rounded-2xl' : 'rounded-b-2xl',
};
const border: Record<DrawerSide, string> = { right: 'border-s', left: 'border-e', bottom: 'border-t', top: 'border-b' };
const percent = snaps ? formatPercent(Math.round(snaps[snap] * 100), locale) : '';
const handle = vertical && (
<button
type="button"
data-drawer-handle=""
aria-label={t.handle}
aria-describedby={`${id}-hh`}
onKeyDown={onHandleKey}
onClick={(e) => {
if (e.detail === 0 || !snaps) return;
setSnap(snap === snaps.length - 1 ? 0 : snap + 1);
}}
className={cn(
'group/handle relative mx-auto grid h-6 w-full shrink-0 cursor-grab touch-none place-items-center outline-none active:cursor-grabbing',
'after:absolute after:inset-x-0 after:-inset-y-2.5',
side === 'top' && 'order-last',
)}
>
<span className="h-1 w-10 rounded-full bg-border-strong transition-[width,background-color] duration-200 group-hover/handle:w-12 group-hover/handle:bg-[color-mix(in_oklab,var(--foreground)_40%,transparent)] group-focus-visible/handle:w-12 group-focus-visible/handle:bg-primary group-focus-visible/handle:outline-2 group-focus-visible/handle:outline-offset-4 group-focus-visible/handle:outline-solid group-focus-visible/handle:outline-ring" />
<span id={`${id}-hh`} className="sr-only">
{t.handleHint}
</span>
</button>
);
const edgeGrip = !vertical && dismissible && (
<span
aria-hidden
{...dragProps}
title={t.edge}
className={cn('absolute inset-y-0 z-10 grid w-5 cursor-grab touch-none place-items-center active:cursor-grabbing', side === 'right' ? '-start-2.5' : '-end-2.5')}
>
<span className="h-10 w-1 rounded-full bg-border-strong transition-[height] duration-200 hover:h-14" />
</span>
);
const panel = (
<ParentContext.Provider value={setChildOpen}>
<div
ref={panelRef}
role={native ? undefined : 'dialog'}
aria-modal={native ? undefined : modal}
aria-labelledby={`${id}-t`}
aria-describedby={description ? `${id}-d` : undefined}
tabIndex={-1}
onKeyDown={trapTab}
data-nested={childOpen || undefined}
className={cn(
'pointer-events-auto absolute flex flex-col border-border bg-popover text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)] outline-none will-change-transform motion-reduce:!transition-none',
floating ? 'border' : border[side],
place[side],
round[side],
inset,
// A child drawer on top: this one steps back
'data-[nested]:brightness-[0.92] data-[nested]:[scale:0.97]',
className,
)}
style={{ transform, transition: `${transition}, scale 300ms ${EASE}, filter 300ms ${EASE}`, height: snaps ? `${maxSnap * 100}%` : undefined }}
>
{edgeGrip}
{vertical && side === 'bottom' && <div {...dragProps}>{handle}</div>}
<div {...(vertical ? dragProps : {})} className={cn('flex shrink-0 items-start gap-4 px-5 pb-3 sm:px-6', vertical ? (side === 'bottom' ? 'pt-1' : 'pt-5') : 'pt-5 sm:pt-6', vertical && 'cursor-grab touch-none active:cursor-grabbing')}>
<div className="min-w-0 flex-1">
<h2 id={`${id}-t`} data-drawer-title="" tabIndex={-1} className="flex items-center gap-2 text-[17px] font-semibold leading-snug tracking-[-0.015em] text-foreground outline-none">
{title}
{meta && <span className="rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">{meta}</span>}
</h2>
{description && (
<p id={`${id}-d`} className="mt-1 text-pretty text-sm leading-relaxed text-muted-foreground">
{description}
</p>
)}
</div>
{!hideClose && (
<button
type="button"
onClick={close}
aria-label={t.close}
className="group/close relative -me-2 -mt-1 grid size-9 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors after:absolute after:-inset-1 hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-ring"
>
<Icon name="x" size={17} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/close:rotate-90 motion-reduce:transition-none" />
</button>
)}
</div>
{children && <div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-5 sm:px-6">{children}</div>}
{footer && <div className="shrink-0 border-t border-border bg-surface px-5 py-4 sm:px-6 [&>*]:max-sm:w-full">{typeof footer === 'function' ? footer(close) : footer}</div>}
{vertical && side === 'top' && <div {...dragProps}>{handle}</div>}
{snaps && (
<span aria-live="polite" className="sr-only">
{shown ? t.height.replace('{p}', percent) : ''}
</span>
)}
</div>
</ParentContext.Provider>
);
const triggerEl =
trigger && isValidElement<Record<string, unknown>>(trigger)
? cloneElement(trigger, {
'aria-haspopup': 'dialog',
'aria-expanded': open,
'aria-controls': open ? id : undefined,
onClick: (e: React.MouseEvent<HTMLElement>) => {
(trigger.props.onClick as ((e: React.MouseEvent) => void) | undefined)?.(e);
returnTo.current = e.currentTarget;
setOpen(!open);
},
})
: trigger;
const scrim = modal && (
<div
aria-hidden
onClick={() => dismissible && close()}
className={cn('absolute inset-0 bg-scrim backdrop-blur-[2px] transition-opacity duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', shown ? 'opacity-100' : 'opacity-0')}
/>
);
let layer: React.ReactNode = null;
if (present) {
if (native) {
layer = (
<dialog
ref={dialogRef}
id={id}
aria-labelledby={`${id}-t`}
aria-describedby={description ? `${id}-d` : undefined}
onCancel={(e) => {
e.preventDefault();
if (dismissible && !childOpen) close();
}}
className="fixed inset-0 m-0 size-full max-h-none max-w-none overflow-hidden bg-transparent p-0 text-left outline-none backdrop:bg-transparent"
>
{scrim}
{panel}
</dialog>
);
} else if (contained) {
layer = (
<div ref={containerRef} id={id} className={cn('absolute inset-0 z-30 overflow-hidden', !modal && 'pointer-events-none', 'rounded-[inherit]')}>
{scrim}
{panel}
</div>
);
} else if (mounted) {
layer = createPortal(
<div id={id} className="pointer-events-none fixed inset-0 z-50 overflow-hidden">
{panel}
</div>,
document.body,
);
}
}
return (
<>
{triggerEl}
{layer}
</>
);
}Häufige Fragen
Wie installiere ich Schublade?
Mit aiw add @ai-website/drawer. Der Befehl legt die Datei components/ui/drawer.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Schublade?
4 Varianten, Vier Kanten, Bottom Sheet mit Rastpunkten, Nicht modale Details und Verschachtelte Filter.
Kann ich die Texte von Schublade übersetzen?
Ja. Schublade bringt 5 eigene Texte auf Deutsch und Englisch mit, etwa „Schließen“, „Höhe ändern“ und „Pfeil hoch und runter ändern die Höhe, Escape schließt.“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Schublade einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Schublade mit aiw_components und installieren sie mit aiw_add @ai-website/drawer.