React drawer
A drawer you can drag. From any edge, bottom sheets with a handle and snap points, non-modal beside a usable page, contained in a frame, and nested with the parent stepping back.
Panels that follow your finger
A drawer slides in from an edge and can be dragged back, more than a sheet that only opens and closes. In modal mode it sits on the native dialog with a focus trap, an inert page, Escape, a scrim, scroll lock and focus back to its button, and phones can get a different side than desktops.
Four edges brings a menu from the left, details from the right, search from the top and share from the bottom, each one follows a swipe back toward its edge. Bottom sheet with snap points rests over a map at three heights, a quick throw carries it to the next point and the map stays usable. Non-modal details opens beside a list that stays usable, without scrim or focus trap, choosing another row updates the drawer in place, and on phones it comes up from the bottom. Nested filters opens all brands in a second drawer on top of the filter drawer, the first steps back until it closes.
Use a non-modal drawer when people need to compare the list and the detail. Keep the handle visible on bottom sheets, it says the panel can move. Nest at most one level, a third drawer is a sign the content wants its own page.
Variants
Details
What Drawer brings along, read from the registry and the source.
- Category
- Overlays
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/drawer.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 5 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The DrawerProps interface from components/ui/drawer.tsx, with the comments from the code.
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
titlerequiredReact.ReactNodedescriptionoptionalReact.ReactNodemetaoptionalReact.ReactNodeSomething next to the title, e.g. a count
footeroptionalReact.ReactNode | ((close: () => void) => React.ReactNode)hideCloseoptionalbooleanchildrenoptionalReact.ReactNode
Built-in texts
Drawer writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 5 texts
| Key | English | German |
|---|---|---|
| close | English Close | German Schließen |
| handle | English Resize the drawer | German Höhe ändern |
| handleHint | English Arrow up and down change the height, Escape closes. | German Pfeil hoch und runter ändern die Höhe, Escape schließt. |
| height | English Drawer at {p} | German Schublade bei {p} |
| edge | English Drag to close | German Zum Schließen ziehen |
Accessibility
Read from the source of the component.
- Drawer handles the keys Escape, Arrow up, Arrow down, Home, End, Tab and Space itself.
- Built on the native dialog element. The browser keeps focus inside and closes it with Escape.
- Sets aria-describedby, aria-label, aria-labelledby, aria-live and aria-modal, so screen readers announce purpose and state.
- Uses the role native.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 476 lines
'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}
</>
);
}Frequent questions
How do I install Drawer?
With aiw add @ai-website/drawer. The command puts components/ui/drawer.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Drawer have?
4 variants, Four edges, Bottom sheet with snap points, Non-modal details and Nested filters.
Can I translate the texts of Drawer?
Yes. Drawer carries 5 texts of its own in English and German, such as “Close”, “Resize the drawer” and “Arrow up and down change the height, Escape closes.”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Drawer?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Drawer with aiw_components and install it with aiw_add @ai-website/drawer.