ATOM
Components (registry)Surfaces

Drawer

A panel slides in from the edge over the page. Filters, mobile nav, and secondary flows.

Preview

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

PropTipoDefaultRango / opcionesWhatHow
openboolean`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>
ClasePropósito
drawerroot
drawer--bottommodifier
drawer--draggingmodifier
drawer--exitingmodifier
drawer--leftmodifier
drawer--rightmodifier
drawer--topmodifier
drawer__bodyelement
drawer__descriptionelement
drawer__footerelement
drawer__handleelement
drawer__headerelement
drawer__titleelement

Tokens resueltos

Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.

VariantePropValorToken
allbg#525252muted.foreground
allfg#525252muted.foreground

Animaciones

PropiedadDuraciónEasing
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

components-react
components/atoms/Drawer.tsx
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

Webflowdrawer

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)

  • selector on .drawer--bottom.drawer--exitingcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .drawer--top.drawer--exitingcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .drawer--left.drawer--exitingcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .drawer--right.drawer--exitingcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .drawer__handle::aftercompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • @media on (prefers-reduced-motion: reduce)not a Designer breakpoint — moved to head Custom Code block

Tras pegar: Create component → nombre Atom / Drawer → Publish.

Componentes relacionados

On this page

Detalles

Publicado14 de mayo de 2026
Categoriasurfaces
Lectura...
Visitas...
Ayuda?Slack

Componente

Drawer

Source

components-react / cssDisponible via MCP: atom_uikit_source("drawer")
Abrir en Storybook