Sheet
A surface rises from the bottom (or side) for a short task. Mobile-friendly alternatives to dialogs.
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Side settings panel:
```tsx import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, } from '@/components/atoms/Sheet';
<Sheet> <SheetTrigger><button type="button">Open</button></SheetTrigger> <SheetContent side="right"> <SheetHeader> <SheetTitle>Settings</SheetTitle> <SheetDescription>Tune preferences.</SheetDescription> </SheetHeader> <SheetBody>{/* fields */}</SheetBody> </SheetContent> </Sheet> ```
## Accesibilidad
- Modal sheet locks body scroll; Escape and overlay close. Include a clear title. - `side` (default **right**) and `showCloseButton` live on `SheetContent`.
## Cuándo no usar
- Centered short forms → `Dialog`. - Drag-from-edge mobile patterns with handle → `Drawer` may fit better.
## Criterio de uso
- Usa Sheet para contenido complementario que acompana a la pagina: filtros, detalle de un registro, ayuda contextual. - Prefierelo a Dialog cuando el usuario necesita alternar entre el panel y la pagina varias veces sin perder el hilo. - Elige el borde por relacion espacial: lateral para navegacion o filtros; inferior para acciones en movil.
## Gotchas
- Mismo contrato controlado que Dialog (`open`/`onOpenChange`): sin el callback no hay forma de cerrarlo. - En movil compite con el gesto de volver del sistema; si el contenido es largo, un Drawer con scroll propio se siente mejor.
Uso
import {
Sheet, SheetTrigger, SheetContent, SheetHeader,
SheetTitle, SheetDescription, SheetBody,
} from '@/components/atoms/Sheet';
<Sheet>
<SheetTrigger><button type="button">Open</button></SheetTrigger>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>Settings</SheetTitle>
<SheetDescription>Tune preferences.</SheetDescription>
</SheetHeader>
<SheetBody>{/* fields */}</SheetBody>
</SheetContent>
</Sheet>Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| open | boolean | — | `true` / `false` | Controlled visibility of the sheet panel. | Omit for uncontrolled Trigger flow. Pass open + onOpenChange for filters/settings opened from app state. |
Gotchas
- react
side (top|right|bottom|left, default right) and showCloseButton live on SheetContent, not the root.
- a11y
Modal sheet locks body scroll; Escape and overlay close. Prefer Sheet for side panels; Dialog for centered tasks.
Anatomía CSS
<div class="sheet"> <span class="sheet__body"></span> <span class="sheet__close"></span> <span class="sheet__description"></span> <span class="sheet__footer"></span> <span class="sheet__header"></span> <span class="sheet__title"></span> </div>
| Clase | Propósito |
|---|---|
sheet | root |
sheet--bottom | modifier |
sheet--exiting | modifier |
sheet--left | modifier |
sheet--right | modifier |
sheet--top | modifier |
sheet__body | element |
sheet__close | element |
sheet__description | element |
sheet__footer | element |
sheet__header | element |
sheet__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 | #ffffff | popover |
| all | fg | #525252 | muted.foreground |
| all | border | 1px | stroke.hairline |
| right | border | 1px | stroke.hairline |
| left | border | 1px | stroke.hairline |
| top | border | 1px | stroke.hairline |
| bottom | border | 1px | stroke.hairline |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
@keyframes sheet-right-in | | |
@keyframes sheet-right-out | | |
@keyframes sheet-left-in | | |
@keyframes sheet-left-out | | |
@keyframes sheet-top-in | | |
@keyframes sheet-top-out | | |
@keyframes sheet-bottom-in | | |
@keyframes sheet-bottom-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).
/* -------------------------------------------------------------------------
Sheet
Side panel extending Dialog. Slides from screen edge.
Reuses dialog__overlay for backdrop.
Sides: top, right, bottom, left
Parts: .sheet, .sheet__header, .sheet__title,
.sheet__description, .sheet__body, .sheet__footer, .sheet__close
------------------------------------------------------------------------- */
/* ---- Content ---- */
.sheet {
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;
border: 1px solid #e5e5e5;
}
.sheet--exiting {
pointer-events: none;
}
/* ---- Sides ---- */
.sheet--right {
top: 0; right: 0; bottom: 0;
width: 100%;
max-width: 400px;
border-left: 1px solid #e5e5e5;
border-right: none;
animation: sheet-right-in 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.sheet--right.sheet--exiting {
animation: sheet-right-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.sheet--left {
top: 0; left: 0; bottom: 0;
width: 100%;
max-width: 400px;
border-right: 1px solid #e5e5e5;
border-left: none;
animation: sheet-left-in 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.sheet--left.sheet--exiting {
animation: sheet-left-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.sheet--top {
top: 0; left: 0; right: 0;
height: auto;
max-height: 80vh;
border-bottom: 1px solid #e5e5e5;
border-top: none;
animation: sheet-top-in 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.sheet--top.sheet--exiting {
animation: sheet-top-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.sheet--bottom {
bottom: 0; left: 0; right: 0;
height: auto;
max-height: 80vh;
border-top: 1px solid #e5e5e5;
border-bottom: none;
animation: sheet-bottom-in 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.sheet--bottom.sheet--exiting {
animation: sheet-bottom-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
/* ---- Slide animations ---- */
@keyframes sheet-right-in { from { transform: translateX(100%); } to { transform: translateX(0); } }
@keyframes sheet-right-out { from { transform: translateX(0); } to { transform: translateX(100%); } }
@keyframes sheet-left-in { from { transform: translateX(-100%); } to { transform: translateX(0); } }
@keyframes sheet-left-out { from { transform: translateX(0); } to { transform: translateX(-100%); } }
@keyframes sheet-top-in { from { transform: translateY(-100%); } to { transform: translateY(0); } }
@keyframes sheet-top-out { from { transform: translateY(0); } to { transform: translateY(-100%); } }
@keyframes sheet-bottom-in { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes sheet-bottom-out { from { transform: translateY(0); } to { transform: translateY(100%); } }
/* ---- Header ---- */
.sheet__header {
display: flex;
flex-direction: column;
gap: 8px;
padding: 24px;
padding-bottom: 0;
}
.sheet__title {
font-size: 20px;
font-weight: 600;
line-height: 1.45;
color: #0a0a0a;
margin: 0;
}
.sheet__description {
font-size: 12.8px;
line-height: 1.45;
color: #525252;
margin: 0;
}
/* ---- Body ---- */
.sheet__body {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* ---- Footer ---- */
.sheet__footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 24px;
padding-top: 0;
}
/* ---- Close ---- */
.sheet__close {
position: absolute;
top: 16px;
right: 16px;
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.sheet,
.sheet--exiting {
animation-duration: 0ms;
}
}
Codigo fuente
import { type ReactNode, useState, useEffect, useCallback, useRef, createContext, useContext, } from 'react'; import { IconButton } from '../atoms/IconButton'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } // Debe coincidir con la animacion de salida de sheet--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(','); const CloseIcon = () => ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <line x1="18" y1="6" x2="6" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" /> </svg> ); /* ---- Context ---- */ type SheetContextValue = { open: boolean; setOpen: (v: boolean) => void; }; const SheetContext = createContext<SheetContextValue | null>(null); function useSheet() { const ctx = useContext(SheetContext); if (!ctx) throw new Error('Sheet components must be used within <Sheet>'); return ctx; } /* ---- Root ---- */ export type SheetProps = { open?: boolean; onOpenChange?: (open: boolean) => void; children: ReactNode; }; export function Sheet({ open: controlledOpen, onOpenChange, children }: SheetProps) { const [internalOpen, setInternalOpen] = useState(false); const open = controlledOpen ?? internalOpen; const setOpen = useCallback( (v: boolean) => { onOpenChange ? onOpenChange(v) : setInternalOpen(v); }, [onOpenChange], ); return ( <SheetContext.Provider value={{ open, setOpen }}> {children} </SheetContext.Provider> ); } /* ---- Trigger ---- */ export function SheetTrigger({ children, className }: { children: ReactNode; className?: string }) { const { setOpen } = useSheet(); 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 SheetContentProps = { side?: 'top' | 'right' | 'bottom' | 'left'; showCloseButton?: boolean; children: ReactNode; className?: string; }; export function SheetContent({ side = 'right', showCloseButton = true, children, className, }: SheetContentProps) { const { open, setOpen } = useSheet(); const [exiting, setExiting] = useState(false); const [mounted, setMounted] = useState(false); const contentRef = useRef<HTMLDivElement>(null); // `open` es lo unico que decide si el sheet 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); 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]); useEffect(() => { if (!mounted) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [mounted, handleClose]); 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 sheet; al cerrar vuelve a quien lo abrio, que es // donde estaba el usuario antes de la interrupcion. 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]); if (!mounted) return null; return ( <> <div className={cn('dialog__overlay', exiting && 'dialog__overlay--exiting')} onClick={handleClose} aria-hidden="true" /> <div ref={contentRef} role="dialog" aria-modal="true" tabIndex={-1} className={cn('sheet', `sheet--${side}`, exiting && 'sheet--exiting', className)} > {children} {showCloseButton && ( <IconButton variant="tertiary" size="xs" className="sheet__close" icon={<CloseIcon />} aria-label="Close" onClick={handleClose} /> )} </div> </> ); } /* ---- Header ---- */ export function SheetHeader({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('sheet__header', className)}>{children}</div>; } /* ---- Title ---- */ export function SheetTitle({ children, className }: { children: ReactNode; className?: string }) { return <h2 className={cn('sheet__title', className)}>{children}</h2>; } /* ---- Description ---- */ export function SheetDescription({ children, className }: { children: ReactNode; className?: string }) { return <p className={cn('sheet__description', className)}>{children}</p>; } /* ---- Body ---- */ export function SheetBody({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('sheet__body', className)}>{children}</div>; } /* ---- Footer ---- */ export function SheetFooter({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('sheet__footer', className)}>{children}</div>; }
Webflow
Pega en el Designer como application/json, luego convierte a Component (Atom / Sheet) 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.sheet--right.sheet--exiting— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.sheet--left.sheet--exiting— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.sheet--top.sheet--exiting— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.sheet--bottom.sheet--exiting— 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 / Sheet → Publish.