Spinner
A spinning mark that says “still working” without blocking the page. Inline loads and button pending states.
Sizes: xssml (default: m)
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Inline pending state:
```tsx import { Spinner } from '@/components/atoms/Spinner';
<Spinner size="m" /> ```
## Accesibilidad
- Root already exposes `role="status"` and `aria-label="Loading"` — do not nest another live region around it. - Pair with disabled buttons or reserved layout so users know *what* is loading.
### Correcto
- role='status' permite que screen readers anuncien el loading state - aria-label='Loading' da texto accesible al spinner - currentColor adapta el contraste al contexto automaticamente - Reduced motion: spinner queda visible pero sin rotacion (aceptable por WCAG)
### Evitar
- No usar Spinner sin contexto visual o textual que indique que se esta cargando - No usar Spinner como decoracion — es un indicador funcional de estado - No cambiar la duracion a menos de 500ms — la rotacion se vuelve frenetica
## Cuándo no usar
- Full card/page content placeholders → `Skeleton`. - Blocking modal progress with a determinate bar → progress patterns, not an endless spinner alone.
## Criterio de uso
- Usa Spinner para trabajo breve e indeterminado en una zona concreta; el usuario debe poder identificar qué está esperando. - En botones, combina el spinner con estado disabled o loading para impedir reintentos accidentales. - Para operaciones largas o con progreso conocido, muestra porcentaje, etapas o una barra determinable en lugar de girar indefinidamente.
## Gotchas
- El componente ya expone `role="status"`; no lo envuelvas en otra live region que duplique el anuncio. - Un spinner aislado no explica el alcance de la espera: reserva espacio y conserva el contexto visible. - **Nota**: 12 lineas de CSS. Junto con Skeleton, los dos componentes mas ligeros del DS. El SVG inline es obligatorio — no se referencia externamente. - **Ojo**: prefers-reduced-motion: reduce pone animation-duration a 0ms — el spinner queda visible pero estatico. Esto es una excepcion aceptable segun WCAG: el spinner comunica estado esencial, pero la animacion no es esencial para entenderlo (el role='status' + aria-label ya comunican 'loading').
Uso
import { Spinner } from '@/components/atoms/Spinner';
<Spinner size="m" />Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| size | select | m | `xs`, `s`, `m`, `l` | Visual scale of the spinner mark. | m inside buttons and default UI; xs/s for dense tables; l for page-level loading. Default: m. |
Gotchas
- a11y
Root already exposes role=status and aria-label=Loading; do not nest another live region around it.
- react
Decorative only — pair with disabled buttons or skeleton content so users know what is loading.
Anatomía CSS
<div class="spinner"> </div>
| Clase | Propósito |
|---|---|
spinner | root |
spinner--l | modifier |
spinner--m | modifier |
spinner--s | modifier |
spinner--xs | modifier |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | fg | currentColor | (literal) |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
@keyframes spinner-rotate | | |
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).
/* -------------------------------------------------------------------------
Spinner
Loading indicator. Rotating circle with partial stroke.
Sizes: xs, s, m, l. Inherits color from parent.
Parts: .spinner
------------------------------------------------------------------------- */
.spinner {
display: inline-flex;
align-items: center;
justify-content: center;
color: currentColor;
animation: spinner-rotate 700ms linear infinite;
}
/* ---- Sizes ---- */
.spinner--xs { width: 0.875rem; height: 0.875rem; }
.spinner--s { width: 1.125rem; height: 1.125rem; }
.spinner--m { width: 1.5rem; height: 1.5rem; }
.spinner--l { width: 2rem; height: 2rem; }
.spinner svg {
width: 100%;
height: 100%;
}
@keyframes spinner-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.spinner {
animation-duration: 0ms;
}
}
Codigo fuente
function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } export type SpinnerProps = { size?: 'xs' | 's' | 'm' | 'l'; className?: string; }; export function Spinner({ size = 'm', className }: SpinnerProps) { return ( <span role="status" aria-label="Loading" className={cn('spinner', `spinner--${size}`, className)}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> <path d="M12 2a10 10 0 0 1 10 10" /> </svg> </span> ); }
Webflow
Pega en el Designer como application/json, luego convierte a Component (Atom / Spinner) 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)
selectoron.spinner svg— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)@mediaon(prefers-reduced-motion: reduce)— not a Designer breakpoint — moved to head Custom Code block
Tras pegar: Create component → nombre Atom / Spinner → Publish.