Zum Inhalt springen
UI Kit
Accent

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.

4 VariantsEine von 11 Components in Overlays

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

Vier KantenNeu in 1.2

Menü von links, Details von rechts, Suche von oben, Teilen von unten. Jede folgt einem Wisch zurück zu ihrer Kante, Escape und die dunkle Fläche schließen sie, der Fokus kehrt zu ihrem Knopf zurück.

Bottom Sheet mit RastpunktenNeu in 1.2

Ein Sheet über einer Karte, das auf drei Höhen einrastet. Am Griff oder Kopf ziehen, ein schneller Wurf trägt es zum nächsten Punkt, unter dem niedrigsten schließt es. Die Karte bleibt bedienbar, nichts wird abgedunkelt.

Nicht modale DetailsNeu in 1.2

Details öffnen neben einer Liste, die bedienbar bleibt, ohne Abdunklung und ohne Fokusfalle. Eine andere Zeile aktualisiert die Schublade an Ort und Stelle. Auf dem Telefon kommt sie stattdessen von unten.

Verschachtelte FilterNeu in 1.2

Eine modale Filter-Schublade auf dem nativen Dialog, auf großen Bildschirmen von links, auf dem Telefon von unten. Alle Marken öffnen in einer zweiten Schublade darüber, die erste tritt zurück, bis sie schließt.

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

Props

Die Schnittstelle DrawerProps aus components/ui/drawer.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • triggeroptional
    React.ReactElement | null

    The button that opens it

  • openoptional
    boolean
  • defaultOpenoptional
    boolean
  • onOpenChangeoptional
    (open: boolean) => void
  • sideoptional
    DrawerSide

    leftrighttopbottom

  • mobileSideoptional
    DrawerSide

    leftrighttopbottom

    The side on phones below 640 px, e.g. bottom for a side drawer

  • modaloptional
    boolean

    Modal: focus stays inside, the page is inert and dimmed. Off: the page stays usable next to the drawer.

  • containedoptional
    boolean

    Lives inside its positioned parent instead of the window (a phone frame, a panel, a map)

  • snapPointsoptional
    number[]

    Heights for a top or bottom drawer as parts of the available height, e.g. [0.25, 0.55, 0.92]

  • defaultSnapoptional
    number

    Index of the snap point it opens at

  • onSnapChangeoptional
    (index: number) => void
  • dismissibleoptional
    boolean

    Swipe toward the edge, Escape and the scrim close it

  • sizeoptional
    'sm' | 'md' | 'lg'

    Width of a side drawer

  • floatingoptional
    boolean

    Inset from the edges with all corners rounded

  • titlePflicht
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • metaoptional
    React.ReactNode

    Something next to the title, e.g. a count

  • footeroptional
    React.ReactNode | ((close: () => void) => React.ReactNode)
  • hideCloseoptional
    boolean
  • childrenoptional
    React.ReactNode

Eingebaute Texte

Diese Texte schreibt Schublade selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 5 Texte anzeigen
closeEnglisch CloseDeutsch Schließen
handleEnglisch Resize the drawerDeutsch Höhe ändern
handleHintEnglisch Arrow up and down change the height, Escape closes.Deutsch Pfeil hoch und runter ändern die Höhe, Escape schließt.
heightEnglisch Drawer at {p}Deutsch Schublade bei {p}
edgeEnglisch Drag to closeDeutsch 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
components/ui/drawer.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert