# sebs7n-ui 0.1.0
> Design system para React: Geist (el lenguaje visual de Vercel) sobre las primitivas de shadcn/ui base-nova (Base UI), empaquetado como una sola dependencia. Tailwind v4, Base UI, React 19, Next 15/16.
Generado de https://ui.sebastianfermanelli.com. Cada sección es una página del sitio; la misma URL + ".md" devuelve solo esa sección.
---
Fuente: https://ui.sebastianfermanelli.com/docs/instalacion
# Instalación
> Una dependencia, un `@import` y tres variables de marca.
Una dependencia, un `@import` y tres variables de marca. No hay `tailwind.config.js` ni archivos copiados al repo de la app.
## Instalar
```bash
pnpm add sebs7n-ui @base-ui/react next-themes sonner geist
```
```bash
npm install sebs7n-ui @base-ui/react next-themes sonner geist
```
> **Todavía no está en npm.** Hasta que se publique, el paquete se distribuye como tarball: `npm pack` en el repo y `pnpm add ./sebs7n-ui-0.1.0.tgz`, o `pnpm add github:sebafermanelli/sebs7n-ui#v0.1.0`. Todo lo de abajo vale igual en los dos casos.
Las `peerDependencies` las instala la app, para que haya **una sola copia** de React y de Base UI:
| Peer | Rango |
|---|---|
| `react` · `react-dom` | `^19.2.0` |
| `@base-ui/react` | `^1.8.0` |
| `next-themes` | `^0.4.6` |
| `sonner` | `^2.0.7` |
## Compatibilidad
| | Versión |
|---|---|
| React | 19.2+ (Server Components y `"use client"`) |
| Next.js | 15 o 16, App Router (Turbopack o webpack) |
| Tailwind CSS | **v4** — tokens por `@theme`, sin `tailwind.config.js` |
| Base UI | `@base-ui/react` 1.8+ |
| TypeScript | `moduleResolution: "bundler"` (o `node16` / `nodenext`) |
| Módulos | Solo ESM |
Los componentes no dependen de Next: funcionan en cualquier bundler que entienda `exports` y la directiva `"use client"`. Lo único específico de Next en los ejemplos es `next/link` y `next/navigation`.
## 1. CSS
En `globals.css`, **en este orden**:
```css
@import "tailwindcss";
@import "sebs7n-ui/theme.css";
/* Las clases de los componentes las genera el Tailwind de la app, en una sola
hoja ordenada. La ruta es relativa a ESTE archivo:
app/globals.css → "../node_modules/…"; src/app/globals.css → "../../node_modules/…". */
@source "../node_modules/sebs7n-ui/dist";
:root {
--brand-base: oklch(0.573 0.214 258);
--brand-base-dark: oklch(0.573 0.214 258);
--brand-contrast-dark: #fff;
}
```
> Existe también `@import "sebs7n-ui/styles.css"` (la hoja precompilada), pero con dos hojas de utilidades un `hidden lg:block` de la app pierde contra el `hidden` del paquete. **No combines `@source` con `@import "sebs7n-ui/styles.css"`**: es una cosa o la otra, y la recomendada es `@source`.
## 2. Layout raíz
`GeistSans.variable` y `GeistMono.variable` en ``, el `ThemeProvider` de `next-themes` con `attribute="class"`, y `TooltipProvider` + `` de `sebs7n-ui`.
```tsx
import { GeistMono } from "geist/font/mono"
import { GeistSans } from "geist/font/sans"
import { Toaster, TooltipProvider } from "sebs7n-ui"
import { ThemeProvider } from "next-themes"
import "./globals.css"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
```
`suppressHydrationWarning` en `` no es opcional: `next-themes` escribe la clase `dark` antes de hidratar y sin eso React avisa en cada carga.
Geist tiene que ser la **fuente variable** (rango `100 900`): la corrección óptica de los títulos usa pesos intermedios (450, 500, 550) que con una estática se redondean.
## 3. Usar
```tsx
import { Button } from "sebs7n-ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "sebs7n-ui/card"
export function Panel() {
return (
Facturas
)
}
```
## Imports por componente
Cada módulo tiene su entry point. **En páginas de marketing o landing, importá por subpath**; el barrel queda para dashboards, que igual usan casi todo.
```tsx
import { Button } from "sebs7n-ui/button"
import { Card, CardContent } from "sebs7n-ui/card"
import { ThemeSwitcher } from "sebs7n-ui/theme-switcher"
import { buttonVariants } from "sebs7n-ui/variants/button" // sin "use client"
import { cn } from "sebs7n-ui/lib/utils"
```
| Subpath | Archivo |
|---|---|
| `sebs7n-ui/` | `src/components/.tsx` (kebab-case: `alert-dialog`, `app-shell`, `user-menu`, …) |
| `sebs7n-ui/variants/` | `button`, `badge`, `card`, `link`, `menu`, `sidebar`, `input`, `toggle` |
| `sebs7n-ui/lib/` | `utils` |
Por qué: el barrel hace `export *` de ~30 módulos `"use client"`. Next no puede podar referencias cliente a través de ese barrel (tampoco con `optimizePackageImports`), así que una página con `Button` + `Card` + `ThemeSwitcher` se lleva también Sonner, Sidebar, Select, AlertDialog y el resto. Medido en Next 16.3 (Turbopack) con esa página: **297,5 KB → 234,8 KB** de JS cliente gzip (−21 %).
No mezcles barrel y subpaths en la misma página: el barrel vuelve a traer todo.
## Verificar que quedó bien
1. `bg-blue-500` **no** tiene que compilar: la paleta de Tailwind está reseteada y solo existen los tokens del paquete.
2. En oscuro, un `Input` tiene que verse **más claro** que el fondo de la página (`#0a0a0a` sobre `#000`). Si son el mismo negro, falta la 2.0 o hay un parche viejo pisando `--sf-background-100`.
3. Tabulá: la primera parada de un `AppShell` es «Ir al contenido».
---
Fuente: https://ui.sebastianfermanelli.com/docs/tokens
# Tokens
> Color, tipografía, radios y sombras. Los valores salen de `tokens/geist.json` y de `theme.css`.
La paleta, los radios y las sombras por defecto de Tailwind están **reseteados**: `bg-blue-50` o `shadow-md` no compilan. Solo existen los tokens del paquete.
Los valores viven en `tokens/geist.json` y se compilan a `src/styles/colors.css` con `npm run tokens`; un test falla si el CSS quedó desactualizado. Las tablas de esta página se generan de esos mismos archivos.
## Fondos: página, superficie y banda
Son tres roles distintos y cada uno tiene su token. Elegir mal se nota sobre todo en oscuro, donde la página es negro puro:
| Token | Rol | Claro | Oscuro |
|---|---|---|---|
| `bg-background` | La página. `body` (ya lo pone el paquete) y la raíz del `AppShell`. | #ffffff | #000000 |
| `bg-background-100` | La superficie: lo que flota sobre la página. Input, Select, Textarea, popup de menú, Popover, Dialog, Sheet, Card, Alert, Toast, barra mobile del shell. | #ffffff | #0a0a0a |
| `bg-background-200` | El fondo sutil / banda: Sidebar, `thead`/`tfoot` de Table, `Card variant="subtle"`, `EmptyState`. | #fafafa | #0a0a0a |
**La regla:** si el elemento **es** la página, `bg-background`; si flota **sobre** ella, `bg-background-100`.
Los valores de oscuro son los de vercel.com medidos con `getComputedStyle` (contact/sales, 2026-09-22): `--ds-background-100: hsla(0,0%,4%)` = `#0a0a0a` para las superficies y `--ds-background-200: hsla(0,0%,0%)` = `#000` para la página. `background-200` es siempre el tono que **no** es el de la página: en claro baja a `#fafafa`, en oscuro no puede bajar de `#000` y sube a `#0a0a0a`. Por eso en oscuro coincide con `background-100`: Geist tiene dos fondos por tema, no tres.
## Color
Diez pasos por familia, de `100` (el más claro en tema claro) a `1000`. La convención de Geist: **100–400 son fondos, 500–700 son bordes y elementos, 800–1000 son texto**. El contraste de `gray-900` sobre cualquier fondo de la misma familia llega a AA.
### gray
| Paso | Claro | Oscuro |
|---|---|---|
| `gray-100` | #f2f2f2 | #1a1a1a |
| `gray-200` | #ebebeb | #1f1f1f |
| `gray-300` | #e6e6e6 | #292929 |
| `gray-400` | #eaeaea | #2e2e2e |
| `gray-500` | #c9c9c9 | #454545 |
| `gray-600` | #a8a8a8 | #878787 |
| `gray-700` | #8f8f8f | #8f8f8f |
| `gray-800` | #7d7d7d | #7d7d7d |
| `gray-900` | #4d4d4d | #a0a0a0 |
| `gray-1000` | #171717 | #ededed |
### gray-alpha
| Paso | Claro | Oscuro |
|---|---|---|
| `gray-alpha-100` | #0000000d | #ffffff12 |
| `gray-alpha-200` | #00000015 | #ffffff17 |
| `gray-alpha-300` | #0000001a | #ffffff21 |
| `gray-alpha-400` | #00000014 | #ffffff24 |
| `gray-alpha-500` | #00000036 | #ffffff3d |
| `gray-alpha-600` | #0000003d | #ffffff82 |
| `gray-alpha-700` | #00000070 | #ffffff8a |
| `gray-alpha-800` | #00000082 | #ffffff78 |
| `gray-alpha-900` | #000000b3 | #ffffff9c |
| `gray-alpha-1000` | #000000e8 | #ffffffeb |
### blue
| Paso | Claro | Oscuro |
|---|---|---|
| `blue-100` | #f0f7ff | #06193a |
| `blue-200` | #eaf4ff | #022248 |
| `blue-300` | #e0efff | #002f62 |
| `blue-400` | #cce7ff | #003771 |
| `blue-500` | #97ccff | #004287 |
| `blue-600` | #51aeff | #0090ff |
| `blue-700` | #0070f7 | #0071f6 |
| `blue-800` | #005edc | #005fd8 |
| `blue-900` | #0064e2 | #50a8ff |
| `blue-1000` | #002453 | #ebf6ff |
### red
| Paso | Claro | Oscuro |
|---|---|---|
| `red-100` | #ffeef0 | #330a11 |
| `red-200` | #ffe9ea | #440d13 |
| `red-300` | #ffe4e5 | #5d0e17 |
| `red-400` | #ffd8d7 | #6f101b |
| `red-500` | #ffb5b6 | #88151f |
| `red-600` | #ff6a6e | #f32e40 |
| `red-700` | #fc0035 | #f13242 |
| `red-800` | #e70022 | #e2162a |
| `red-900` | #d60020 | #ff5e63 |
| `red-1000` | #46000c | #ffeaed |
### amber
| Paso | Claro | Oscuro |
|---|---|---|
| `amber-100` | #fff6e1 | #291800 |
| `amber-200` | #fff4d4 | #331b00 |
| `amber-300` | #fff1c8 | #4f2900 |
| `amber-400` | #ffdd84 | #573200 |
| `amber-500` | #ffc85e | #6c4100 |
| `amber-600` | #ffaa00 | #e99c00 |
| `amber-700` | #ffb200 | #ffb200 |
| `amber-800` | #ff9900 | #ff9900 |
| `amber-900` | #a64f00 | #ff9900 |
| `amber-1000` | #541c00 | #fff3d9 |
### green
| Paso | Claro | Oscuro |
|---|---|---|
| `green-100` | #ecfdec | #00250a |
| `green-200` | #e5fce7 | #003110 |
| `green-300` | #d3fad1 | #003814 |
| `green-400` | #b9f5bc | #004616 |
| `green-500` | #82eb8d | #00661d |
| `green-600` | #4ce15e | #009431 |
| `green-700` | #28a948 | #00ab3e |
| `green-800` | #279141 | #009335 |
| `green-900` | #107d32 | #00ca52 |
| `green-1000` | #00370d | #daffe5 |
### teal
| Paso | Claro | Oscuro |
|---|---|---|
| `teal-100` | #dffffb | #00211b |
| `teal-200` | #ddfef6 | #002922 |
| `teal-300` | #ccf9f1 | #003b33 |
| `teal-400` | #b1f7ec | #003f35 |
| `teal-500` | #52f0db | #005f53 |
| `teal-600` | #00e2c4 | #009885 |
| `teal-700` | #00a694 | #00a794 |
| `teal-800` | #008d7d | #008d7d |
| `teal-900` | #007a6e | #00c9b5 |
| `teal-1000` | #003d34 | #cefff5 |
### purple
| Paso | Claro | Oscuro |
|---|---|---|
| `purple-100` | #f9f0ff | #290c33 |
| `purple-200` | #f9f1ff | #341142 |
| `purple-300` | #f5e8ff | #47185e |
| `purple-400` | #f1d9ff | #541a76 |
| `purple-500` | #dda9ff | #642290 |
| `purple-600` | #c77dff | #9440d5 |
| `purple-700` | #9f00f4 | #9440d5 |
| `purple-800` | #8400cd | #7d2bba |
| `purple-900` | #7c00c9 | #c472fb |
| `purple-1000` | #2e004d | #faedff |
### pink
| Paso | Claro | Oscuro |
|---|---|---|
| `pink-100` | #ffeaf5 | #310d1e |
| `pink-200` | #ffeaf2 | #420c25 |
| `pink-300` | #ffe0eb | #571032 |
| `pink-400` | #ffd5e1 | #5d0c34 |
| `pink-500` | #fdb3cc | #76063f |
| `pink-600` | #f97ea7 | #b90056 |
| `pink-700` | #f22782 | #f12b82 |
| `pink-800` | #e4106e | #e6006e |
| `pink-900` | #c41562 | #ff518d |
| `pink-1000` | #460523 | #ffeaf4 |
`brand` no está en la tabla porque no tiene valores fijos: se deriva de las tres variables de la app. Ver [Theming](/docs/theming).
## Tipografía
Las utilidades de Geist. `cn()` las entiende como tamaño de fuente, así que conviven con `text-gray-900`.
| Utilidad | Tamaño | Interlineado | Peso | Tracking |
|---|---|---|---|---|
| `text-heading-72` | 72px | 72px | 400 | -4.32px |
| `text-heading-64` | 64px | 64px | 400 | -3.84px |
| `text-heading-56` | 56px | 56px | 450 | -3.36px |
| `text-heading-48` | 48px | 56px | 450 | -2.88px |
| `text-heading-40` | 40px | 48px | 450 | -2.4px |
| `text-heading-32` | 32px | 40px | 500 | -1.28px |
| `text-heading-24` | 24px | 32px | 500 | -0.96px |
| `text-heading-20` | 20px | 26px | 550 | -0.4px |
| `text-heading-16` | 16px | 24px | 600 | -0.32px |
| `text-heading-14` | 14px | 20px | 600 | -0.28px |
| `text-button-16` | 16px | 20px | 500 | 0 |
| `text-button-14` | 14px | 20px | 500 | 0 |
| `text-button-12` | 12px | 16px | 500 | 0 |
| `text-label-20` | 20px | 32px | 400 | 0 |
| `text-label-18` | 18px | 20px | 400 | 0 |
| `text-label-16` | 16px | 20px | 400 | 0 |
| `text-label-14` | 14px | 20px | 400 | 0 |
| `text-label-13` | 13px | 16px | 400 | 0 |
| `text-label-12` | 12px | 16px | 400 | 0 |
| `text-label-14-mono` | 14px | 20px | 400 | 0 |
| `text-label-13-mono` | 13px | 20px | 400 | 0 |
| `text-label-12-mono` | 12px | 16px | 400 | 0 |
| `text-copy-24` | 24px | 36px | 400 | 0 |
| `text-copy-20` | 20px | 36px | 400 | 0 |
| `text-copy-18` | 18px | 28px | 400 | 0 |
| `text-copy-16` | 16px | 24px | 400 | 0 |
| `text-copy-14` | 14px | 20px | 400 | 0 |
| `text-copy-13` | 13px | 18px | 400 | 0 |
| `text-copy-14-mono` | 14px | 20px | 400 | 0 |
| `text-copy-13-mono` | 13px | 18px | 400 | 0 |
**Los `heading` llevan el peso corregido ópticamente**, no 600 fijo. Un mismo peso no se ve igual a 14px que a 64px: cuanto más grande el cuerpo, más gruesos se leen los trazos, y 600 a 64px sale plomizo. Los números salen de medir vercel.com (computed style, 2026-09-22); los pasos que no aparecían ahí se interpolan en la misma curva.
**No lo pises con `font-semibold`**: para eso está el paso de arriba de la escala. Y necesita Geist como fuente variable (rango `100 900`); con una estática los pesos intermedios se redondean y la corrección se pierde.
Cuándo usar cada familia:
| Familia | Para qué |
|---|---|
| `heading-*` | Títulos. Peso óptico, tracking negativo. |
| `copy-*` | Párrafos y texto corrido. Interlineado holgado. |
| `label-*` | Etiquetas, celdas, metadatos. Interlineado ajustado, peso 400. |
| `button-*` | Texto dentro de controles. Peso 500. |
| `*-mono` | Código, IDs, importes. Geist Mono. |
## Radios
| Utilidad | Valor |
|---|---|
| `rounded-xs` | 4px |
| `rounded-sm` | 6px |
| `rounded-md` | 6px |
| `rounded-lg` | 12px |
| `rounded-xl` | 12px |
| `rounded-2xl` | 16px |
| `rounded-3xl` | 16px |
| `rounded-4xl` | 9999px |
`rounded-md` (6px) es el radio del sistema: botones, inputs, ítems de menú. `rounded-xl` (12px) es el de las tarjetas. `rounded-full` solo en `Badge`, en avatares y en `Button shape="pill"`.
## Sombras
| Utilidad | Dónde |
|---|---|
| `shadow-tooltip` | Tooltip. |
| `shadow-menu` | DropdownMenu, Select, Combobox, Popover. |
| `shadow-modal` | Dialog, AlertDialog, Sheet. |
Las tres terminan en un anillo de 1px que en oscuro reemplaza al borde. No hay sombras decorativas: una sombra en este sistema significa «esto flota», y solo flotan los tres tipos de superficie de arriba.
## Foco y movimiento
| Utilidad | Dónde |
|---|---|
| `focus-visible:focus-ring` | Controles: botones, ítems de menú, tabs, toggles. Anillo de 2px en `brand-700` con hueco. |
| `focus:focus-border` | Campos: Input, Textarea, Select, Combobox. Tiñe el borde y agrega un halo. |
| `focus:focus-border-error` | Lo mismo, en rojo, cuando el campo tiene `aria-invalid`. |
| `transition-control` | 150 ms, `ease`, solo color / fondo / borde / sombra / opacidad. |
| `animate-skeleton` | El latido del `Skeleton`. |
Todas las animaciones pasan por `motion-reduce`, además del reset global del paquete.
---
Fuente: https://ui.sebastianfermanelli.com/docs/theming
# Theming
> Tres variables de marca, claro y oscuro, radio y densidad.
Una app define **tres variables** y nada más. No hay que tocar ningún archivo del paquete ni recompilar nada.
## Color de marca
```css
:root {
--brand-base: oklch(0.55 0.16 35); /* acento en claro */
--brand-base-dark: oklch(0.55 0.16 35); /* acento en oscuro; por defecto, igual a la base */
--brand-contrast-dark: #fff; /* texto sobre el acento en oscuro */
}
```
De ahí el paquete deriva la escala `brand-100…1000` y `brand-contrast`, con `oklch(from …)`: se fija la luminosidad del paso equivalente de `blue` en Geist y se escala el croma. Por eso un acento naranja y uno azul dan escalas que «pesan» igual.
**La regla es una sola: el texto sobre `brand-700` tiene que llegar a 4,5:1.** Si el acento es claro, `--brand-contrast-dark: #000`. Hay un test en el paquete que recalcula el ratio desde OKLCH y falla si una marca no da.
`tokens/brands.json` trae cuatro marcas de ejemplo (`teal`, `terracotta`, `emerald`, `blue`) que usan el playground y los tests de contraste. **Son solo demos del sistema**: una app real no las usa ni edita ese archivo.
### Dónde aparece el acento
| Token | Dónde |
|---|---|
| `brand-700` | `Button variant="accent"`, `Switch variant="accent"`, anillo de foco, borde de `Card selected`. |
| `brand-800` | Hover del acento. |
| `brand-900` | `Button variant="link"`, texto de `Badge color="brand"`, `linkVariants`. |
| `brand-100…400` | Fondos y bordes suaves: `Badge variant="subtle" color="brand"`. |
| `brand-contrast` | El texto **encima** del acento. |
**Un solo acento por pantalla.** Si el CTA principal es `accent`, el switch de al lado no.
## Claro y oscuro
Por clase (`.dark` en ``), vía `next-themes` con `attribute="class"`:
```tsx
```
Dos controles para cambiarlo:
- **`ThemeSwitcher`** — barra segmentada de tres estados, para fuera de un menú: header público, página de ajustes.
- **`ThemeMenuRadio`** — los mismos tres estados como `menuitemradio` dentro de un `DropdownMenu` propio. `UserMenu` ya lo trae.
Sin `enableSystem` ninguno de los dos muestra la opción «Sistema».
El paquete define `color-scheme` en `html` y `html.dark`, así que los scrollbars, los `