ATOM
Components (registry)Surfaces

Sheet

A surface rises from the bottom (or side) for a short task. Mobile-friendly alternatives to dialogs.

Preview

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

PropTipoDefaultRango / opcionesWhatHow
openboolean`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>
ClasePropósito
sheetroot
sheet--bottommodifier
sheet--exitingmodifier
sheet--leftmodifier
sheet--rightmodifier
sheet--topmodifier
sheet__bodyelement
sheet__closeelement
sheet__descriptionelement
sheet__footerelement
sheet__headerelement
sheet__titleelement

Tokens resueltos

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

VariantePropValorToken
allbg#ffffffpopover
allfg#525252muted.foreground
allborder1pxstroke.hairline
rightborder1pxstroke.hairline
leftborder1pxstroke.hairline
topborder1pxstroke.hairline
bottomborder1pxstroke.hairline

Animaciones

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

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

Webflowsheet

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)

  • selector on .sheet--right.sheet--exitingcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .sheet--left.sheet--exitingcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .sheet--top.sheet--exitingcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .sheet--bottom.sheet--exitingcompound/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 / Sheet → Publish.

Componentes relacionados

On this page

Detalles

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

Componente

Sheet

Source

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