Patrones
BetaCómo se combinan los componentes para resolver las situaciones que se repiten en todo producto.
Un componente resuelve una pieza; un patrón resuelve una situación. Estos son los que aparecen en casi cualquier pantalla de FDS. Cada uno está armado con los componentes reales del sistema: copiá el código como punto de partida.
Formulario
"use client";
import { useState } from "react";
import { Button } from "@/registry/ds/button";
import { Checkbox } from "@/registry/ds/checkbox";
import { Input } from "@/registry/ds/input";
import { Radio, RadioGroup } from "@/registry/ds/radio";
import { Select } from "@/registry/ds/select";
export default function PatternForm() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const bad = sent && !/@/.test(email);
return (
<form
className="flex w-full max-w-sm flex-col gap-5"
onSubmit={(e) => {
e.preventDefault();
setSent(true);
}}
>
<Input label="Nombre completo" autoComplete="name" required placeholder="Como figura en tu DNI" />
<Input
label="Correo institucional"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
error={bad ? "Falta el @ — usá tu correo @universidad.edu." : undefined}
hint={bad ? undefined : "Te enviamos ahí la confirmación."}
/>
<Select label="Sede" required options={[{ label: "Campus Norte", value: "n" }, { label: "Campus Centro", value: "c" }, { label: "Virtual", value: "v" }]} />
<RadioGroup label="Jornada" defaultValue="m" orientation="horizontal" name="jornada">
<Radio value="m" label="Mañana" />
<Radio value="t" label="Tarde" />
<Radio value="n" label="Noche" />
</RadioGroup>
<Checkbox required label="Acepto el reglamento académico" />
<div className="flex justify-end gap-3 pt-1">
<Button variant="text" type="reset" onClick={() => { setSent(false); setEmail(""); }}>Cancelar</Button>
<Button type="submit">Inscribirme</Button>
</div>
</form>
);
}- Una columna. Los campos en una sola columna se completan más rápido y funcionan igual en mobile. Dos columnas solo para pares cortos (ciudad / CP).
- Label arriba, siempre visible. El placeholder es un ejemplo, no el nombre del campo.
- Marcá lo opcional, no lo obligatorio, cuando la mayoría es obligatoria.
- Validá al salir del campo o al enviar, nunca mientras se escribe. El error va junto al campo y dice cómo arreglarlo.
- El botón principal dice qué pasa: "Inscribirme", no "Enviar". Cancelar
va a la izquierda, en
text. - Al enviar,
loadingen el botón; al terminar, un Toast o la pantalla siguiente. - Campos:
autoCompleteeinputModecorrectos activan el teclado y el autocompletado adecuados.
Cargando, con datos, vacío
Toda lista o panel tiene tres estados, y hay que diseñar los tres.
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/registry/ds/button";
import { Card, CardBody, CardDescription, CardTitle } from "@/registry/ds/card";
import { EmptyState } from "@/registry/ds/empty-state";
import { Search } from "@/registry/ds/icons";
import { Skeleton } from "@/registry/ds/skeleton";
type State = "loading" | "data" | "empty";
export default function PatternLoading() {
const [state, setState] = useState<State>("loading");
useEffect(() => {
if (state !== "loading") return;
const id = setTimeout(() => setState("data"), 1800);
return () => clearTimeout(id);
}, [state]);
return (
<div className="flex w-full max-w-md flex-col gap-4">
<div className="flex gap-2">
{(["loading", "data", "empty"] as const).map((s) => (
<Button key={s} size="sm" variant={state === s ? "tonal" : "stroke"} onClick={() => setState(s)}>
{{ loading: "Cargando", data: "Con datos", empty: "Vacío" }[s]}
</Button>
))}
</div>
<Card>
{state === "loading" && (
<CardBody className="gap-3" role="status" aria-busy="true" aria-label="Cargando materias">
<Skeleton width="55%" className="text-lg" />
<Skeleton />
<Skeleton width="85%" />
</CardBody>
)}
{state === "data" && (
<CardBody>
<CardTitle>Cálculo I</CardTitle>
<CardDescription>Lunes y miércoles, 8:00. Comisión 2, aula 204. Quedan 12 lugares.</CardDescription>
</CardBody>
)}
{state === "empty" && (
<EmptyState size="sm" icon={<Search />} title="No hay materias en este horario" description="Probá con otra jornada o quitá el filtro de sede." actions={<Button size="sm" variant="stroke">Quitar filtros</Button>} />
)}
</Card>
</div>
);
}| Estado | Componente | Regla |
|---|---|---|
| Cargando | Skeleton con la forma del contenido | Aparece después de ~300 ms; si pasa de ~10 s, mensaje. |
| Con datos | El contenido | — |
| Vacío | Empty state | Dice por qué no hay nada y qué hacer. Distinto para "primera vez", "sin resultados" y "error". |
El contenedor no cambia de tamaño entre estados: el skeleton reserva el lugar.
Acción destructiva
Confirmar, ejecutar y dejar deshacer.
"use client";
import { useState } from "react";
import { Button } from "@/registry/ds/button";
import { AlertTriangle } from "@/registry/ds/icons";
import { Modal, ModalClose, ModalFooter, ModalHeader } from "@/registry/ds/modal";
import { ToastProvider, useToast } from "@/registry/ds/toast";
function Flow() {
const toast = useToast();
const [gone, setGone] = useState(false);
if (gone) {
return (
<Button variant="stroke" onClick={() => setGone(false)}>
Restaurar (demo)
</Button>
);
}
return (
<Modal size="sm" trigger={<Button variant="stroke">Dar de baja Cálculo I</Button>}>
<ModalHeader icon={<AlertTriangle />} align="center" closable={false} title="¿Dar de baja Cálculo I?" subtitle="Perdés el cupo. Podés volver a inscribirte el próximo semestre." />
<ModalFooter layout="full">
<ModalClose>
<Button variant="stroke">Cancelar</Button>
</ModalClose>
<ModalClose>
<Button
onClick={() => {
setGone(true);
toast({ title: "Diste de baja Cálculo I", description: "Tenés 10 segundos para deshacerlo.", timeout: 10000, action: { label: "Deshacer", onClick: () => setGone(false) } });
}}
>
Dar de baja
</Button>
</ModalClose>
</ModalFooter>
</Modal>
);
}
export default function PatternDestructive() {
return (
<ToastProvider>
<Flow />
</ToastProvider>
);
}- Modal de confirmación
sm, icono de alerta, título con la pregunta completa y el nombre de la cosa ("¿Dar de baja Cálculo I?"). El subtítulo dice la consecuencia. - Footer
full: Cancelar primero (recibe el foco), la acción con verbo + objeto. Sin ✕: hay un Cancelar visible. - Al ejecutar, un Toast con Deshacer y un
timeoutlargo (10 s). Si no se puede deshacer, decilo en el modal.
Estructura de app
Navbar arriba con marca y acciones; Sidebar a la izquierda con la navegación; el contenido ocupa el resto y es lo único que hace scroll.
Inicio
Hola Julia. Tenés 2 finales esta semana y una inscripción abierta.
Cálculo II · Final jueves 9:00
Física I · Final viernes 14:00
import { Avatar } from "@/registry/ds/avatar";
import { Button } from "@/registry/ds/button";
import { BarChart, Bell, BookOpen, Calendar, Home, Settings } from "@/registry/ds/icons";
import { Navbar, NavbarActions, NavbarBrand, NavbarMobile } from "@/registry/ds/navbar";
import { Sidebar, SidebarGroup, SidebarItem, SidebarNav } from "@/registry/ds/sidebar";
const Logo = () => (
<svg viewBox="0 0 24 24" aria-hidden>
<rect width="24" height="24" rx="6" fill="var(--primary)" />
<path d="M7 12.5l3.5 3.5L17 9" stroke="var(--primary-fg)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
);
/** La misma navegación en los dos lugares: Sidebar en pantallas anchas, Drawer desde ☰ en angostas */
const items = (
<>
<SidebarItem href="#" icon={<Home />} active>
Inicio
</SidebarItem>
<SidebarGroup label="Académico">
<SidebarItem href="#" icon={<BookOpen />}>
Materias
</SidebarItem>
<SidebarItem href="#" icon={<Calendar />}>
Calendario
</SidebarItem>
<SidebarItem href="#" icon={<BarChart />}>
Notas
</SidebarItem>
</SidebarGroup>
<SidebarGroup label="Cuenta">
<SidebarItem href="#" icon={<Settings />}>
Configuración
</SidebarItem>
</SidebarGroup>
</>
);
export default function AppShell() {
return (
<div className="@container flex h-[30rem] w-full flex-col overflow-hidden rounded-lg bg-bg-subtle">
<Navbar position="static" collapse="sm" width="full">
<NavbarBrand href="#">
<Logo />
Campus
</NavbarBrand>
<NavbarActions>
<Button variant="stroke" size="sm" iconOnly={<Bell />} aria-label="Notificaciones" />
<Avatar name="Julia Arce" size="sm" />
<NavbarMobile>{items}</NavbarMobile>
</NavbarActions>
</Navbar>
<div className="flex min-h-0 flex-1">
<Sidebar collapsible className="@max-[640px]:hidden">
<SidebarNav>{items}</SidebarNav>
</Sidebar>
<main className="min-w-0 flex-1 overflow-y-auto p-6">
<h2 className="text-heading-xs font-semibold text-fg">Inicio</h2>
<p className="mt-1 text-sm text-fg-muted">Hola Julia. Tenés 2 finales esta semana y una inscripción abierta.</p>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
{["Cálculo II · Final jueves 9:00", "Física I · Final viernes 14:00"].map((t) => (
<div key={t} className="rounded-lg border border-border bg-bg-elevated p-4 text-sm text-fg">
{t}
</div>
))}
</div>
</main>
</div>
</div>
);
}- La navegación vive en un solo lugar. El Navbar no repite los destinos del Sidebar: queda con marca, búsqueda, notificaciones y cuenta.
- Angosto, la misma lista en un Drawer. Los
SidebarItemvan adentro deNavbarMobiletal cual; el Sidebar se esconde con@max-[640px]:hiddensobre un contenedor@container. - Plegable, y recordá la preferencia con
onCollapsedChange(cookie o localStorage) para que no vuelva a abrirse en cada carga. - El
<main>llevamin-w-0yoverflow-y-auto: así una tabla ancha no empuja el sidebar. - Breadcrumb y título de página van adentro del
<main>, no en el Navbar.
Feedback: cuál usar
| Quiero… | Usá |
|---|---|
| Confirmar que algo que hice salió bien | Toast |
| Avisar de un estado que sigue ahí (vence, falta, falló) | Alert junto a lo afectado |
| Anunciar algo a todo el sitio | Banner arriba de todo |
| Marcar un campo mal completado | error del Input |
| Pedir una decisión antes de seguir | Modal |
| Indicar el estado de un registro | Chip status |
Navegación
- Secciones hermanas de una misma cosa → Tabs.
- Dónde estoy en un sitio profundo → Breadcrumb.
- Pasos de un proceso → Stepper arriba del formulario.
- Listas largas → Pagination; feeds → "Cargar más".
- Acciones sobre un ítem → Dropdown menu en un "…".
- Detalle sin salir de la lista → Drawer.
Jerarquía de acciones en una pantalla
- Un solo botón Fill por zona: la acción que la persona vino a hacer.
- Secundarias en
strokeotonal; terciarias y "Cancelar" entext. - Acciones de utilidad (exportar, filtrar) en
strokecon icono, o en un menú. - La acción principal va a la derecha en desktop y arriba (primera) en mobile cuando se apilan.