Button
BetaDispara una acción. Cuatro estilos para marcar jerarquía, cuatro tamaños, dos radios y cuatro formas de llevar icono.
registry/ds/button.tsximport { Button } from "@/registry/ds/button";
import { ArrowUpRight } from "@/registry/ds/icons";
export default function ButtonBasic() {
return (
<Button iconMode="pill" iconRight={<ArrowUpRight />}>
Inscribirme
</Button>
);
}Cuándo usarlo
Un Button hace algo: envía, guarda, confirma, abre un flujo. Si lo que pasa al hacer clic es ir a otro lugar, probablemente sea un link.
| Usá Button para… | Usá otra cosa para… |
|---|---|
| Enviar un formulario, confirmar, guardar. | Navegar dentro de un texto → Link. |
| Abrir un modal, un drawer o un flujo. | Elegir entre opciones excluyentes → Tabs o Radio. |
| La llamada a la acción de una sección. | Prender/apagar una preferencia → Switch. |
| Acciones de utilidad con icono (buscar, cerrar). | Filtrar o etiquetar → Chip. |
Anatomía
- 1Contenedor — Da el alto (padding + contenido), el fondo y el radio. Es el área clickeable.
- 2Icono izquierdo — Opcional. Refuerza el significado de la acción (agregar, descargar).
- 3Label — Verbo + objeto. Una sola línea, nunca se corta.
- 4Icono derecho — Opcional. Indica dirección o consecuencia (avanzar, abrir afuera).
Jerarquía de estilos
El estilo no es estética: es cuánto grita la acción. Cada sección tiene un solo Fill; el resto acompaña.
import { Button } from "@/registry/ds/button";
export default function ButtonVariants() {
return (
<div className="flex flex-wrap items-center gap-4">
<Button variant="fill">Fill</Button>
<Button variant="stroke">Stroke</Button>
<Button variant="tonal">Tonal</Button>
<Button variant="text">Text</Button>
</div>
);
}| Estilo | Énfasis | Para qué |
|---|---|---|
| Fill | Máximo | La acción principal de la sección. Una sola. |
| Stroke | Alto | Secundaria con peso: la alternativa real a la principal. |
| Tonal | Medio | Secundaria suave, acciones repetidas en listas y cards. |
| Text | Bajo | Terciaria, cancelar, "ver más". La zona gris con el link. |
Un solo Fill marca el camino. Las demás acciones bajan de énfasis en orden.
Si todo es principal, nada lo es: la persona tiene que leer los tres para decidir.
Tamaños
import { Button } from "@/registry/ds/button";
export default function ButtonSizes() {
return (
<div className="flex flex-wrap items-end gap-4">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
<Button size="xl">Extra large</Button>
</div>
);
}| Size | Alto | Dónde |
|---|---|---|
sm | 32 | UI densa: tablas, toolbars, filtros. |
md | 40 | El default de producto. |
lg | 48 | Formularios protagonistas y mobile. |
xl | 64 | Heros y CTAs de marketing. |
En una misma fila, todos los controles comparten tamaño — un Input md va con
un Button md.
Radius
El radio lo decide el contexto de la página, no el gusto: Full para superficies de marketing y vida estudiantil, Semi para lo institucional. Ver Radius.
import { Button } from "@/registry/ds/button";
export default function ButtonRadius() {
return (
<div className="flex flex-wrap items-center gap-4">
<Button radius="full">Full — marketing</Button>
<Button radius="semi">Semi — institucional</Button>
</div>
);
}Mismo radio y mismo tamaño en los botones que conviven.
No mezcles Full con Semi ni tamaños distintos en el mismo grupo.
Iconos
Cuatro modos, iguales a la propiedad Icon del componente en Figma:
inside(default) — el glifo directo, en el color del label. UI de producto.pill— el glifo sobre un círculo. CTAs donde el botón es protagonista.outside— un círculo flotante del alto del botón, separado 4 px. Marketing.iconOnly— solo el glifo. Acciones de utilidad reconocibles, siempre conaria-label.
import { Button } from "@/registry/ds/button";
import { ArrowUpRight, Plus, Search } from "@/registry/ds/icons";
export default function ButtonIcons() {
return (
<div className="flex flex-col gap-4">
{/* inside — el glifo directo, color del label */}
<div className="flex flex-wrap items-center gap-4">
<Button iconLeft={<Plus />}>Nueva materia</Button>
<Button variant="stroke" iconRight={<ArrowUpRight />}>
Ver programa
</Button>
</div>
{/* pill — círculo de fondo alrededor del glifo */}
<div className="flex flex-wrap items-center gap-4">
<Button iconMode="pill" iconRight={<ArrowUpRight />}>
Inscribirme
</Button>
<Button variant="tonal" iconMode="pill" iconLeft={<ArrowUpRight />}>
Conocer el campus
</Button>
</div>
{/* outside — círculo flotante fuera del cuerpo */}
<div className="flex flex-wrap items-center gap-4">
<Button iconMode="outside" iconRight={<ArrowUpRight />}>
Empezar ahora
</Button>
<Button variant="stroke" iconMode="outside" iconLeft={<ArrowUpRight />}>
Ver carreras
</Button>
</div>
{/* icon only — requiere aria-label */}
<div className="flex flex-wrap items-center gap-4">
<Button iconOnly={<Search />} aria-label="Buscar" />
<Button variant="stroke" iconOnly={<Plus />} aria-label="Agregar" />
<Button variant="text" iconOnly={<ArrowUpRight />} aria-label="Abrir" />
</div>
</div>
);
}Cargando
Cuando la acción tarda, loading reemplaza el contenido por un spinner sin
cambiar el ancho del botón, bloquea el segundo clic y mantiene el foco donde
estaba. Usalo cuando la espera supera ~1 segundo; para menos, no muestres nada.
"use client";
import { useState } from "react";
import { Button } from "@/registry/ds/button";
export default function ButtonLoading() {
const [saving, setSaving] = useState(false);
// Simula un guardado: el botón conserva su ancho y el foco mientras espera
const save = () => {
setSaving(true);
setTimeout(() => setSaving(false), 2000);
};
return (
<div className="flex flex-wrap items-center gap-4">
<Button loading={saving} onClick={save}>
Guardar cambios
</Button>
<Button variant="stroke" loading>
Guardando
</Button>
<Button variant="tonal" loading>
Exportando
</Button>
</div>
);
}Ancho completo
fullWidth para formularios angostos, mobile y pies de modal. En desktop, un
botón de ancho completo en una columna ancha se vuelve una barra: evitalo.
import { Button } from "@/registry/ds/button";
export default function ButtonFullWidth() {
return (
<div className="flex w-72 flex-col gap-3">
<Button fullWidth size="lg">
Crear cuenta
</Button>
<Button fullWidth size="lg" variant="stroke">
Ya tengo cuenta
</Button>
</div>
);
}Contenido
- Verbo + objeto: "Descargar programa", "Crear cuenta". No "OK", no "Enviar" a secas si hay ambigüedad.
- Mayúscula solo al inicio ("Guardar cambios", no "Guardar Cambios").
- 2–3 palabras. Si necesitás más, el contexto alrededor está fallando.
- Sin puntuación final ni "haz clic".
- El mismo label para la misma acción en todo el producto.
Corto, específico, empieza con verbo.
El botón no explica la interfaz. Y un label largo se rompe en mobile.
Medidas
El alto no es un número mágico: es padding del cuerpo + contenido, igual que el auto-layout del componente en Figma.
| Size | Alto | Padding cuerpo | Label (alto / px) | Glifo | Tipografía | Radius Semi |
|---|---|---|---|---|---|---|
sm | 32 | spacing-xs 4 | 24 / 8 | 16 | Text sm / Medium | radius-sm 6 |
md | 40 | spacing-sm 6 | 28 / 10 | 20 | Text md / Medium | radius-md 8 |
lg | 48 | spacing-md 8 | 32 / 12 | 24 | Text md / Medium | radius-lg 10 |
xl | 64 | spacing-xl 12 | 40 / 16 | 24 | Text lg / Medium | radius-xl 12 |
- Gap entre cuerpo y círculo
outside:spacing-xs(4). - Borde de Stroke:
border-md(1.5). Borde de Tonal:border-sm(1). - Focus ring: 2 px,
focus/ring, por fuera del borde. - Disabled:
opacity-disabled(38%) sobre el componente entero.
Tokens por estilo y estado
Los nombres son las variables de la colección Semantic — las mismas a las que está bindeado el component set en Figma.
Fill
| Parte | Propiedad | Default | Hover | Pressed |
|---|---|---|---|---|
| Contenedor | Fondo | primary/solid | primary/solid-hover | primary/solid-active |
| Label e icono | Color | primary/on-solid | primary/on-solid | primary/on-solid |
Stroke
| Parte | Propiedad | Default | Hover | Pressed |
|---|---|---|---|---|
| Contenedor | Fondo | bg/elevated | primary/faint | primary/subtle |
| Contenedor | Borde | primary/accent | primary/accent | primary/accent |
| Label e icono | Color | primary/accent | primary/accent | primary/accent |
Tonal
| Parte | Propiedad | Default | Hover | Pressed |
|---|---|---|---|---|
| Contenedor | Fondo | primary/subtle | primary/subtle-hover | primary/subtle-active |
| Contenedor | Borde | primary/border | primary/border | primary/border |
| Label e icono | Color | primary/text | primary/text | primary/text |
Text
| Parte | Propiedad | Default | Hover | Pressed |
|---|---|---|---|---|
| Label e icono | Color | primary/accent | primary/accent-hover | primary/accent-active |
| Label | Decoración | — | underline | underline |
En Figma
El componente es Button en 🌻 FDS Components. Todo lo que se configura en el panel de propiedades tiene su equivalente en código:
| Propiedad en Figma | Valores | En código | Nota |
|---|---|---|---|
| Style | Fill · Stroke · Tonal · Text | variant | — |
| Size | sm · md · lg · xl | size | — |
| — | — | loading · fullWidth · href | Todavía solo en código; pendientes de sumar al component set. |
| Radius | Full · Semi | radius | — |
| Icon | Inside · Inside pill · Outside · Icon only | iconMode / iconOnly | Icon only es una prop aparte en código. |
| Icon left / Icon right | on · off | iconLeft / iconRight | En código pasás el icono directamente. |
| Icon swap | Cualquier icono de FDS Iconography | <Icono /> | — |
| Label | Texto | children | — |
| State | Default · Hover · Focus · Pressed · Disabled | — | Solo para maquetar; en código los estados son automáticos, salvo disabled. |
Instalación
npx shadcn@latest add https://polen.cdc.cool/r/button.jsonTrae también los iconos que usa. El components.json y los tokens se configuran una sola vez por proyecto (ver Para developers).
Playground
Configurá el botón y copiá el snippet — el código se genera desde el mismo estado que renderiza el preview.
<Button>Inscribirme</Button>Ejemplos
Con iconos
import { Button } from "@/registry/ds/button";
import { ArrowUpRight, Plus, Search } from "@/registry/ds/icons";
export default function ButtonIcons() {
return (
<div className="flex flex-col gap-4">
{/* inside — el glifo directo, color del label */}
<div className="flex flex-wrap items-center gap-4">
<Button iconLeft={<Plus />}>Nueva materia</Button>
<Button variant="stroke" iconRight={<ArrowUpRight />}>
Ver programa
</Button>
</div>
{/* pill — círculo de fondo alrededor del glifo */}
<div className="flex flex-wrap items-center gap-4">
<Button iconMode="pill" iconRight={<ArrowUpRight />}>
Inscribirme
</Button>
<Button variant="tonal" iconMode="pill" iconLeft={<ArrowUpRight />}>
Conocer el campus
</Button>
</div>
{/* outside — círculo flotante fuera del cuerpo */}
<div className="flex flex-wrap items-center gap-4">
<Button iconMode="outside" iconRight={<ArrowUpRight />}>
Empezar ahora
</Button>
<Button variant="stroke" iconMode="outside" iconLeft={<ArrowUpRight />}>
Ver carreras
</Button>
</div>
{/* icon only — requiere aria-label */}
<div className="flex flex-wrap items-center gap-4">
<Button iconOnly={<Search />} aria-label="Buscar" />
<Button variant="stroke" iconOnly={<Plus />} aria-label="Agregar" />
<Button variant="text" iconOnly={<ArrowUpRight />} aria-label="Abrir" />
</div>
</div>
);
}Tamaños
import { Button } from "@/registry/ds/button";
export default function ButtonSizes() {
return (
<div className="flex flex-wrap items-end gap-4">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
<Button size="xl">Extra large</Button>
</div>
);
}Como link
Con href renderiza un <a> con la misma apariencia — para cuando la acción
navega pero necesita el peso visual de un botón (un CTA a otra página).
import { Button } from "@/registry/ds/button";
import { ArrowUpRight } from "@/registry/ds/icons";
export default function ButtonLink() {
return (
<Button
href="https://www.figma.com"
target="_blank"
rel="noreferrer"
variant="stroke"
iconRight={<ArrowUpRight />}
>
Abrir en Figma
</Button>
);
}Props
Acepta además todos los atributos nativos de <button> (onClick, type,
form, aria-*…).
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
variant | "fill" | "stroke" | "tonal" | "text" | "fill" | Jerarquía visual del botón. |
size | "sm" | "md" | "lg" | "xl" | "md" | Altura 32 / 40 / 48 / 64 px, con tipografía y padding acordes. |
radius | "full" | "semi" | "full" | Full = pill; Semi usa el radius token del tamaño (6–12 px). |
iconMode | "inside" | "pill" | "outside" | "inside" | Cómo se presentan iconLeft/iconRight: directo, con círculo de fondo, o flotante fuera del cuerpo. |
iconLeft | ReactNode | — | Icono antes del label. |
iconRight | ReactNode | — | Icono después del label. |
iconOnly | ReactNode | — | Solo icono; requiere aria-label. Oculta el label e ignora iconMode. |
loading | boolean | false | Spinner centrado, ancho intacto, clic bloqueado, aria-busy. Conserva el foco. |
fullWidth | boolean | false | Ocupa el 100% del contenedor. |
href | string | — | Renderiza <a>. Acepta target, rel y download. |
disabled | boolean | false | Opacidad 0.38 + pointer-events bloqueados. |
Teclado
| Tecla | Qué hace |
|---|---|
| Tab | Mueve el foco al botón (y al siguiente elemento). |
| ShiftTab | Mueve el foco al elemento anterior. |
| Enter | Activa el botón. |
| Espacio | Activa el botón al soltar la tecla. |
Qué viene resuelto
- Es un
<button>nativo contype="button"por defecto: no envía formularios por accidente. - Focus ring visible (2 px,
focus/ring) solo con teclado. En modooutsideenvuelve cada pieza, como en Figma. - Todo el conjunto —círculos flotantes incluidos— es un solo botón: un target de clic, un stop de tab.
loadingno deshabilita: usaaria-busy+aria-disabled, así el foco no se pierde y el lector de pantalla anuncia que está ocupado. El clic se bloquea también por teclado, no solo conpointer-events. El spinner respetaprefers-reduced-motion.- Con
href, un botón deshabilitado pierde elhrefy se anuncia conaria-disabled(un link no tienedisablednativo). - El alto mínimo (32) supera el área interactiva mínima de 24 px (WCAG 2.5.8).
Qué tenés que cuidar vos
- Icon-only necesita
aria-labelque describa la acción ("Cerrar", no "X"). - Los iconos junto a un label son decorativos: no les pongas texto alternativo.
disabledsaca al botón del orden de tab. Si la persona necesita saber por qué no puede avanzar, dejalo habilitado y mostrá el error al hacer clic.- No comuniques solo con color: un botón destructivo lo dice en el label ("Eliminar cuenta").
- Si el botón abre algo, avisalo:
aria-haspopup,aria-expanded.
Contraste
| Par | Ratio | Criterio FDS (3:1) |
|---|---|---|
Fill — primary/on-solid sobre primary/solid | 4.08:1 | Cumple |
Stroke y Text — primary/accent sobre blanco | 4.08:1 | Cumple |
Tonal — primary/text sobre primary/subtle | 4.48:1 | Cumple |
Focus ring — focus/ring sobre blanco | 2.37:1 | No llega — en revisión |