Drawer
A panel slides in from the edge over the page. Filters, mobile nav, and secondary flows.
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Mobile filters:
```tsx import { Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerBody, DrawerFooter, } from '@/components/atoms/Drawer';
<Drawer> <DrawerTrigger><button type="button">Filters</button></DrawerTrigger> <DrawerContent direction="bottom"> <DrawerHeader> <DrawerTitle>Filters</DrawerTitle> <DrawerDescription>Narrow the list.</DrawerDescription> </DrawerHeader> <DrawerBody>{/* controls */}</DrawerBody> <DrawerFooter><button type="button">Apply</button></DrawerFooter> </DrawerContent> </Drawer> ```
## Accesibilidad
- `role="dialog"` `aria-modal`; Escape and overlay close. Prefer bottom on mobile, side for desktop filters. - `direction` is on `DrawerContent` (default bottom); drag past threshold dismisses.
## Cuándo no usar
- Centered multi-field tasks → `Dialog`. - Permanent app navigation → `Sidebar`.
## Criterio de uso
- Usa Drawer cuando el panel es el foco principal de la interaccion y puede ocupar casi toda la pantalla: formularios largos, listas filtrables. - Es la eleccion natural en movil donde Dialog resulta estrecho y Sheet corto. - Mantén el encabezado y las acciones visibles mientras el cuerpo hace scroll.
## Gotchas
- El contenido largo necesita scroll en el cuerpo, no en toda la pagina: si el fondo tambien se desplaza, cerrar devuelve al usuario a otra posicion. - Comparte contrato controlado con Dialog y Sheet; el criterio de eleccion es de tamano y foco, no tecnico.
Uso
import {
Drawer, DrawerTrigger, DrawerContent, DrawerHeader,
DrawerTitle, DrawerDescription, DrawerBody, DrawerFooter,
} from '@/components/atoms/Drawer';
<Drawer>
<DrawerTrigger><button type="button">Filters</button></DrawerTrigger>
<DrawerContent direction="bottom">
<DrawerHeader>
<DrawerTitle>Filters</DrawerTitle>
<DrawerDescription>Narrow the list.</DrawerDescription>
</DrawerHeader>
<DrawerBody>{/* controls */}</DrawerBody>
<DrawerFooter><button type="button">Apply</button></DrawerFooter>
</DrawerContent>
</Drawer>Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| open | boolean | — | `true` / `false` | Controlled visibility of the drawer. | Omit for uncontrolled Trigger flow. Pass open + onOpenChange for mobile nav or filter panels from app state. |
Gotchas
- react
direction (top|right|bottom|left, default bottom) is on DrawerContent, not the root. Drag handle dismisses past 100px threshold.
- a11y
role=dialog aria-modal; Escape and overlay click close. Prefer bottom drawers on mobile, side drawers for desktop filters.
- layout
Locks body scroll while open; avoid nesting another scroll-lock modal without testing.
Anatomía CSS
<div class="drawer"> <span class="drawer__body"></span> <span class="drawer__description"></span> <span class="drawer__footer"></span> <span class="drawer__handle"></span> <span class="drawer__header"></span> <span class="drawer__title"></span> </div>
| Clase | Propósito |
|---|---|
drawer | root |
drawer--bottom | modifier |
drawer--dragging | modifier |
drawer--exiting | modifier |
drawer--left | modifier |
drawer--right | modifier |
drawer--top | modifier |
drawer__body | element |
drawer__description | element |
drawer__footer | element |
drawer__handle | element |
drawer__header | element |
drawer__title | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | bg | #525252 | muted.foreground |
| all | fg | #525252 | muted.foreground |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
none | !important | |
@keyframes drawer-bottom-in | | |
@keyframes drawer-bottom-out | | |
@keyframes drawer-top-in | | |
@keyframes drawer-top-out | | |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Drawer
Touch-dismissible panel. Extends Sheet with drag gesture.
Swipe to dismiss. Visual drag indicator.
Directions: bottom (default), top, left, right
Parts: .drawer, .drawer__handle, .drawer__header, .drawer__title,
.drawer__description, .drawer__body, .drawer__footer
------------------------------------------------------------------------- */
/* ---- Content ---- */
.drawer {
position: fixed;
z-index: 50;
display: flex;
flex-direction: column;
background-color: #ffffff;
color: #0a0a0a;
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
touch-action: none;
}
/* ---- Directions ---- */
.drawer--bottom {
bottom: 0; left: 0; right: 0;
max-height: 85vh;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
animation: drawer-bottom-in 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.drawer--bottom.drawer--exiting {
animation: drawer-bottom-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.drawer--top {
top: 0; left: 0; right: 0;
max-height: 85vh;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
animation: drawer-top-in 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.drawer--top.drawer--exiting {
animation: drawer-top-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.drawer--left {
top: 0; left: 0; bottom: 0;
max-width: 400px;
width: 100%;
animation: sheet-left-in 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.drawer--left.drawer--exiting {
animation: sheet-left-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.drawer--right {
top: 0; right: 0; bottom: 0;
max-width: 400px;
width: 100%;
animation: sheet-right-in 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.drawer--right.drawer--exiting {
animation: sheet-right-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
/* Slide animations for top/bottom */
@keyframes drawer-bottom-in { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes drawer-bottom-out { from { transform: translateY(0); } to { transform: translateY(100%); } }
@keyframes drawer-top-in { from { transform: translateY(-100%); } to { transform: translateY(0); } }
@keyframes drawer-top-out { from { transform: translateY(0); } to { transform: translateY(-100%); } }
/* ---- Drag handle ---- */
.drawer__handle {
display: flex;
justify-content: center;
padding: 12px 0 4px;
cursor: grab;
}
.drawer__handle::after {
content: "";
width: 40px;
height: 4px;
border-radius: 9999px;
background-color: #525252;
opacity: 0.3;
}
.drawer__handle:active {
cursor: grabbing;
}
/* ---- Drag state ---- */
.drawer--dragging {
transition: none !important;
animation: none !important;
}
/* ---- Header ---- */
.drawer__header {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 24px;
text-align: center;
}
.drawer__title {
font-size: 20px;
font-weight: 600;
line-height: 1.45;
color: #0a0a0a;
margin: 0;
}
.drawer__description {
font-size: 12.8px;
line-height: 1.45;
color: #525252;
margin: 0;
}
/* ---- Body ---- */
.drawer__body {
flex: 1;
overflow-y: auto;
padding: 0 24px;
}
/* ---- Footer ---- */
.drawer__footer {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.drawer,
.drawer--exiting {
animation-duration: 0ms;
}
}
Codigo fuente
import { type ReactNode, type TouchEvent, type MouseEvent as ReactMouseEvent, useState, useEffect, useCallback, useRef, createContext, useContext, } from 'react'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } /* ---- Context ---- */ type DrawerContextValue = { open: boolean; setOpen: (v: boolean) => void; }; const DrawerContext = createContext<DrawerContextValue | null>(null); function useDrawer() { const ctx = useContext(DrawerContext); if (!ctx) throw new Error('Drawer components must be used within <Drawer>'); return ctx; } /* ---- Root ---- */ export type DrawerProps = { open?: boolean; onOpenChange?: (open: boolean) => void; children: ReactNode; }; export function Drawer({ open: controlledOpen, onOpenChange, children }: DrawerProps) { const [internalOpen, setInternalOpen] = useState(false); const open = controlledOpen ?? internalOpen; const setOpen = useCallback( (v: boolean) => { onOpenChange ? onOpenChange(v) : setInternalOpen(v); }, [onOpenChange], ); return ( <DrawerContext.Provider value={{ open, setOpen }}> {children} </DrawerContext.Provider> ); } /* ---- Trigger ---- */ export function DrawerTrigger({ children, className }: { children: ReactNode; className?: string }) { const { setOpen } = useDrawer(); return ( <div role="button" tabIndex={0} className={className} style={{ display: 'inline-flex' }} onClick={() => setOpen(true)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(true); } }} > {children} </div> ); } /* ---- Content ---- */ export type DrawerContentProps = { direction?: 'top' | 'right' | 'bottom' | 'left'; children: ReactNode; className?: string; }; const DISMISS_THRESHOLD = 100; // Debe coincidir con la animacion de salida de drawer--exiting en el CSS. const EXIT_DURATION_MS = 200; const FOCUSABLE = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(','); export function DrawerContent({ direction = 'bottom', children, className }: DrawerContentProps) { const { open, setOpen } = useDrawer(); const [exiting, setExiting] = useState(false); const [mounted, setMounted] = useState(false); const [dragOffset, setDragOffset] = useState(0); const [dragging, setDragging] = useState(false); const startPos = useRef(0); const contentRef = useRef<HTMLDivElement>(null); // `open` es lo unico que decide si el drawer esta abierto. Si el consumidor lo // controla y veta el cierre, el contenido tiene que quedarse: desmontar desde // aqui lo dejaba cerrado para siempre, porque `open` ya nunca vuelve a cambiar. // `mounted` solo estira la vida en el DOM lo que dura la animacion de salida. useEffect(() => { if (open) { setMounted(true); setExiting(false); setDragOffset(0); return; } if (!mounted) return; setExiting(true); const timer = setTimeout(() => { setMounted(false); setExiting(false); }, EXIT_DURATION_MS); return () => clearTimeout(timer); }, [open, mounted]); const handleClose = useCallback(() => { setOpen(false); }, [setOpen]); // Escape useEffect(() => { if (!mounted) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [mounted, handleClose]); // Scroll lock useEffect(() => { if (!mounted) return; const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; }, [mounted]); // Al abrir, el foco entra al drawer; al cerrar vuelve a quien lo abrio. Antes no // se movia en absoluto: el foco se quedaba en el disparador, detras del overlay. useEffect(() => { if (!mounted) return; const opener = document.activeElement as HTMLElement | null; contentRef.current?.focus(); return () => { if (opener && document.contains(opener)) opener.focus(); }; }, [mounted]); // Focus trap real: con aria-modal el lector de pantalla ya ignora el fondo, pero // el Tab del navegador no, y sin esto el foco se escapa a la pagina de atras. useEffect(() => { if (!mounted) return; const node = contentRef.current; if (!node) return; const handler = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; const focusables = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE)); if (focusables.length === 0) { e.preventDefault(); node.focus(); return; } const first = focusables[0]; const last = focusables[focusables.length - 1]; const active = document.activeElement; const outside = !node.contains(active); if (e.shiftKey && (active === first || active === node || outside)) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && (active === last || outside)) { e.preventDefault(); first.focus(); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [mounted]); // Drag helpers const isVertical = direction === 'top' || direction === 'bottom'; const getTranslate = () => { if (dragOffset === 0) return undefined; if (isVertical) return `translateY(${dragOffset}px)`; return `translateX(${dragOffset}px)`; }; const handleDragStart = (clientX: number, clientY: number) => { startPos.current = isVertical ? clientY : clientX; setDragging(true); }; const handleDragMove = (clientX: number, clientY: number) => { if (!dragging) return; const current = isVertical ? clientY : clientX; let delta = current - startPos.current; // Only allow dragging in dismiss direction if (direction === 'bottom' && delta < 0) delta = 0; if (direction === 'top' && delta > 0) delta = 0; if (direction === 'right' && delta < 0) delta = 0; if (direction === 'left' && delta > 0) delta = 0; setDragOffset(delta); }; const handleDragEnd = () => { setDragging(false); if (Math.abs(dragOffset) > DISMISS_THRESHOLD) { handleClose(); } else { setDragOffset(0); } }; // Touch events const onTouchStart = (e: TouchEvent) => { handleDragStart(e.touches[0].clientX, e.touches[0].clientY); }; const onTouchMove = (e: TouchEvent) => { handleDragMove(e.touches[0].clientX, e.touches[0].clientY); }; const onTouchEnd = () => handleDragEnd(); // Mouse events (for desktop testing) const onMouseDown = (e: ReactMouseEvent) => { handleDragStart(e.clientX, e.clientY); const onMove = (ev: MouseEvent) => handleDragMove(ev.clientX, ev.clientY); const onUp = () => { handleDragEnd(); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }; if (!mounted) return null; return ( <> <div className={cn('dialog__overlay', exiting && 'dialog__overlay--exiting')} style={{ opacity: dragOffset ? 1 - Math.abs(dragOffset) / 300 : undefined }} onClick={handleClose} aria-hidden="true" /> <div ref={contentRef} role="dialog" aria-modal="true" tabIndex={-1} className={cn( 'drawer', `drawer--${direction}`, exiting && 'drawer--exiting', dragging && 'drawer--dragging', className, )} style={{ transform: getTranslate() }} > <div className="drawer__handle" onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} onMouseDown={onMouseDown} /> {children} </div> </> ); } /* ---- Header ---- */ export function DrawerHeader({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('drawer__header', className)}>{children}</div>; } /* ---- Title ---- */ export function DrawerTitle({ children, className }: { children: ReactNode; className?: string }) { return <h2 className={cn('drawer__title', className)}>{children}</h2>; } /* ---- Description ---- */ export function DrawerDescription({ children, className }: { children: ReactNode; className?: string }) { return <p className={cn('drawer__description', className)}>{children}</p>; } /* ---- Body ---- */ export function DrawerBody({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('drawer__body', className)}>{children}</div>; } /* ---- Footer ---- */ export function DrawerFooter({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('drawer__footer', className)}>{children}</div>; }
Webflow
Pega en el Designer como application/json, luego convierte a Component (Atom / Drawer) y publica. Formato interno no documentado de Webflow — regenerable, no dependencia de runtime.
Setup del sitio (una vez)
Custom Code → Head:
<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/tokens.css"> <link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/components.css">
Unsupported (no silencioso)
selectoron.drawer--bottom.drawer--exiting— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.drawer--top.drawer--exiting— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.drawer--left.drawer--exiting— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.drawer--right.drawer--exiting— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.drawer__handle::after— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)@mediaon(prefers-reduced-motion: reduce)— not a Designer breakpoint — moved to head Custom Code block
Tras pegar: Create component → nombre Atom / Drawer → Publish.