Drawer
BetaUn panel que entra desde un borde, para detalles, filtros o edición sin salir de la página.
registry/ds/drawer.tsximport { Button } from "@/registry/ds/button";
import { Chip } from "@/registry/ds/badge";
import { Drawer, DrawerBody, DrawerClose, DrawerFooter, DrawerHeader } from "@/registry/ds/drawer";
export default function DrawerBasic() {
return (
<div className="flex flex-wrap gap-3">
<Drawer trigger={<Button variant="stroke">Ver detalle</Button>}>
<DrawerHeader title="Cálculo I" description="Comisión 2 · Lunes y miércoles, 8:00" />
<DrawerBody className="space-y-4">
<Chip status="success">Con cupo</Chip>
<p>Límites, derivadas e integrales en una variable. Correlativa de Cálculo II y Física I.</p>
<p>Docente: Julia Arce. Aula 204, Campus Centro.</p>
</DrawerBody>
<DrawerFooter>
<DrawerClose>
<Button variant="stroke">Cerrar</Button>
</DrawerClose>
<Button>Inscribirme</Button>
</DrawerFooter>
</Drawer>
<Drawer side="bottom" trigger={<Button variant="tonal">Desde abajo</Button>}>
<DrawerHeader title="Ordenar por" closable={false} />
<DrawerBody className="pb-6">Nombre · Cupo · Horario · Docente</DrawerBody>
</Drawer>
</div>
);
}Drawer o Modal
Los dos interrumpen. El Drawer mantiene el contexto: la página sigue visible al lado y el panel puede ser largo y scrollear. El Modal centra la atención en una decisión corta.
| Usá Drawer para… | Usá Modal para… |
|---|---|
| Ver el detalle de un ítem de una lista. | Confirmar algo. |
| Filtros y ajustes con varios campos. | Un formulario de 2–4 campos. |
| Editar sin perder de vista la lista. | Onboarding, celebración. |
| Menú y acciones en mobile (desde abajo). | — |
Lado
- Derecha (default): detalle y edición.
- Izquierda: navegación.
- Abajo: mobile — hojas de acciones, ordenar, filtros cortos. Tiene asa y se cierra deslizando hacia abajo.
Formulario
Un formulario corto sin salir de la página. El cuerpo scrollea; el footer con Cancelar y la acción primaria queda fijo. Si el formulario tiene más de 6 u 8 campos, es una página.
import { Button } from "@/registry/ds/button";
import { Checkbox } from "@/registry/ds/checkbox";
import { Drawer, DrawerBody, DrawerClose, DrawerFooter, DrawerHeader } from "@/registry/ds/drawer";
import { Plus } from "@/registry/ds/icons";
import { Input, Textarea } from "@/registry/ds/input";
import { Select } from "@/registry/ds/select";
const comisiones = ["Comisión 1 · Lunes 8:00", "Comisión 2 · Martes 14:00", "Comisión 3 · Jueves 19:00"].map((c) => ({ label: c, value: c }));
/** Un formulario corto sin salir de la página: el cuerpo scrollea, el footer con las acciones queda fijo */
export default function DrawerForm() {
return (
<Drawer size="md" trigger={<Button iconLeft={<Plus />}>Nueva consulta</Button>}>
<DrawerHeader title="Consulta al docente" description="Te responde en el campus en 48 h hábiles." />
<DrawerBody className="space-y-5 py-4">
<Select label="Comisión" options={comisiones} defaultValue={comisiones[0].value} />
<Input label="Asunto" placeholder="Duda sobre el TP 2" required />
<Textarea label="Consulta" placeholder="Contá qué parte no te cierra, y qué probaste." hint="Hasta 1000 caracteres." required />
<Checkbox size="sm" label="Avisarme por correo cuando respondan" defaultChecked />
</DrawerBody>
<DrawerFooter>
<DrawerClose>
<Button variant="stroke">Cancelar</Button>
</DrawerClose>
<DrawerClose>
<Button>Enviar consulta</Button>
</DrawerClose>
</DrawerFooter>
</Drawer>
);
}Bottom sheet
Desde abajo, para elegir una opción en móvil. Se cierra al elegir, y el botón que lo abre muestra el valor actual: no hace falta footer.
"use client";
import { useState } from "react";
import { Button } from "@/registry/ds/button";
import { Drawer, DrawerBody, DrawerHeader } from "@/registry/ds/drawer";
import { Radio, RadioGroup } from "@/registry/ds/radio";
const opciones = [
{ value: "nombre", label: "Nombre", supporting: "A → Z" },
{ value: "cupo", label: "Cupo", supporting: "Primero las que tienen más lugares" },
{ value: "horario", label: "Horario", supporting: "Más temprano primero" },
];
/** Bottom sheet: desde abajo, para elegir una opción en móvil. Se cierra al elegir y el botón muestra el valor */
export default function DrawerBottomSheet() {
const [open, setOpen] = useState(false);
const [orden, setOrden] = useState("nombre");
const actual = opciones.find((o) => o.value === orden)!;
return (
<Drawer side="bottom" open={open} onOpenChange={setOpen} trigger={<Button variant="stroke">Ordenar por: {actual.label}</Button>}>
<DrawerHeader title="Ordenar por" closable={false} />
<DrawerBody className="pb-6">
<RadioGroup
aria-label="Ordenar por"
value={orden}
onValueChange={(v) => {
setOrden(String(v));
setOpen(false);
}}
>
{opciones.map((o) => (
<Radio key={o.value} value={o.value} label={o.label} supporting={o.supporting} />
))}
</RadioGroup>
</DrawerBody>
</Drawer>
);
}Tamaños y lado
sm (320) para una ficha, md (400) para un formulario, lg (560) para
contenido largo. Desde la izquierda, para navegación.
import { Button } from "@/registry/ds/button";
import { Drawer, DrawerBody, DrawerClose, DrawerFooter, DrawerHeader } from "@/registry/ds/drawer";
const sizes = [
{ size: "sm" as const, title: "Detalle rápido", text: "Una ficha: nombre, estado, dos datos. Lo que entra sin scrollear." },
{ size: "md" as const, title: "Formulario", text: "Campos en una columna, con espacio para hints y errores." },
{ size: "lg" as const, title: "Contenido largo", text: "Tablas, previsualizaciones o un documento. Casi una página." },
];
/** sm (320), md (400) y lg (560): elegí el ancho por el contenido, no por el gusto. Del lado izquierdo, para navegación */
export default function DrawerSizes() {
return (
<div className="flex flex-wrap gap-3">
{sizes.map((s) => (
<Drawer key={s.size} size={s.size} trigger={<Button variant="stroke">{s.size}</Button>}>
<DrawerHeader title={s.title} description={`size="${s.size}"`} />
<DrawerBody>
<p>{s.text}</p>
</DrawerBody>
<DrawerFooter>
<DrawerClose>
<Button variant="stroke">Cerrar</Button>
</DrawerClose>
</DrawerFooter>
</Drawer>
))}
<Drawer side="left" size="sm" trigger={<Button variant="tonal">Desde la izquierda</Button>}>
<DrawerHeader title="Navegación" />
<DrawerBody>
<ul className="flex flex-col gap-1 text-sm">
{["Inicio", "Materias", "Calendario", "Notas"].map((item) => (
<li key={item}>
<a href="#" className="block rounded-md px-3 py-2 font-medium text-fg hover:bg-bg-subtle">
{item}
</a>
</li>
))}
</ul>
</DrawerBody>
</Drawer>
</div>
);
}Reglas
- Header con título; footer con las acciones si hay que guardar.
- Un drawer a la vez; nunca un drawer que abre un modal.
- Al cerrar, la lista de atrás no debe haber cambiado sin avisar.
| Parte | Medida |
|---|---|
| Lateral | sm 320 · md 400 · lg 480 · alto completo |
| Inferior | ancho completo · alto máx. 80 % · radius-2xl arriba · asa 40 × 4 |
| Header | padding 24 (arriba y lados) · Text lg / Semibold |
| Body | padding X 24 · Text sm · scroll interno |
| Footer | padding 24 × 16 · borde superior · gap 12 |
| Entrada | duration/slow (500 ms), curva de hoja |
| Parte | Propiedad | Token |
|---|---|---|
| Scrim | Negro a opacity-scrim | — |
| Panel | Fondo | bg/elevated |
| Panel | Borde interior | border/default |
| Panel | Sombra | Elevation/xl |
| Asa | Fondo | border/strong |
| Título | Color | text/default |
| Descripción y cuerpo | Color | text/muted |
Instalación
npx shadcn@latest add https://polen.cdc.cool/r/drawer.jsonSobre Base UI Drawer. Mismas
piezas que Modal: DrawerHeader, DrawerBody, DrawerFooter, DrawerClose.
Playground
Configurá el componente y copiá el snippet: se genera del mismo estado que renderiza el preview.
<Drawer trigger={<Button variant="stroke">Abrir drawer</Button>}>
<DrawerHeader title="Cálculo I" description="Comisión 2 · Lunes y miércoles, 8:00" />
<DrawerBody>…</DrawerBody>
<DrawerFooter>
<DrawerClose>
<Button variant="stroke">Cerrar</Button>
</DrawerClose>
<Button onClick={save}>Inscribirme</Button>
</DrawerFooter>
</Drawer>| Prop | Tipo | Default | Descripción |
|---|---|---|---|
Drawer · trigger | ReactElement | — | El botón que lo abre. Opcional si es controlado. |
Drawer · side | "right" | "left" | "bottom" | "right" | Borde de entrada. |
Drawer · size | "sm" | "md" | "lg" | "md" | Ancho lateral 320 / 400 / 480. |
Drawer · open · onOpenChange | boolean · (open) => void | — | Modo controlado. |
DrawerHeader · title · description · closable | ReactNode · boolean | — | closable=false solo si el footer tiene Cerrar. |
role="dialog"nombrado por el título; foco atrapado adentro; al cerrar vuelve al disparador; la página de atrás queda inerte.- Esc, clic en el scrim y deslizar hacia el borde cierran.
- El cuerpo scrollea solo; header y footer quedan siempre visibles.
- En mobile respeta el área segura inferior.