Guías de Herramientas

Personalización de Colores en shadcn/ui: Tematización con Variables CSS

9 min de lectura

shadcn/ui se ha convertido en la biblioteca de componentes predeterminada para toda una generación de desarrolladores de React. A diferencia de las bibliotecas tradicionales que distribuyen un paquete precompilado, los componentes de shadcn/ui se copian directamente en tu proyecto —lo que significa que eres dueño del código, incluidas todas las decisiones de color. La biblioteca incluye un sistema de variables CSS basado en HSL bien pensado que hace que la tematización sea sencilla, pero su máximo potencial solo se desbloquea cuando entiendes la arquitectura que hay detrás. Esta guía recorre el sistema de colores de principio a fin, pasando por la creación de temas personalizados, la integración del modo oscuro y la sincronización con tu configuración de Tailwind CSS.

Descripción General de la Arquitectura de Colores de shadcn/ui

Cuando inicializas un proyecto de shadcn/ui (npx shadcn@latest init), el CLI añade un bloque de propiedades personalizadas CSS a tu hoja de estilos global:

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 222.2 84% 4.9%;
    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;
    --secondary: 210 40% 96.1%;
    --secondary-foreground: 222.2 47.4% 11.2%;
    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;
    --accent: 210 40% 96.1%;
    --accent-foreground: 222.2 47.4% 11.2%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;
    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 222.2 84% 4.9%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    /* ... sobreescrituras del modo oscuro ... */
  }
}

Dos decisiones estructurales condicionan todo lo demás:

  1. Nombramiento semántico: Las variables se nombran según sus roles (--primary, --destructive, --muted) en lugar de valores visuales (--blue-600, --red-500). Un componente que usa --primary no sabe ni le importa si primary es azul, violeta o naranja.

  2. Valores HSL solo de canales: Las variables almacenan solo los canales HSL —222.2 84% 4.9%— no una llamada completa a la función hsl(). Esto es intencional: los componentes consumen la variable como hsl(var(--primary)), lo que permite que el modificador de opacidad se componga de forma natural: hsl(var(--primary) / 0.5).

El Sistema de Variables CSS en HSL

Por Qué Solo los Canales HSL

Almacenar el color como 222.2 84% 4.9% (solo canales) en lugar de hsl(222.2, 84%, 4.9%) (un valor completo) habilita un patrón que las variables CSS estándar no soportan de forma elegante:

/* CSS estándar — la composición alfa requiere una variable separada */
:root {
  --primary: hsl(222.2, 84%, 11.2%);
  --primary-alpha-50: hsl(222.2, 84%, 11.2%, 0.5); /* hay que duplicar */
}

/* Patrón shadcn/ui — el alfa se compone sin duplicación */
:root {
  --primary: 222.2 47.4% 11.2%;
}

.element {
  background: hsl(var(--primary));          /* opaco */
  border-color: hsl(var(--primary) / 0.2);  /* 20% de opacidad */
}

El valor de solo canales es un fragmento que solo tiene sentido dentro de hsl(). Esto limita dónde puede usarse la variable, pero esa limitación es la característica: impone una composición de opacidad consistente en todo tu código base.

El Mapa Completo de Variables

shadcn/ui define 16 roles de color (excluyendo --radius):

Variable Propósito Consumidor típico
--background Fondo de página <body>, contenedores de página
--foreground Texto del cuerpo <p>, <span>
--card Superficie de tarjeta <Card>
--card-foreground Texto en tarjetas Contenido de tarjeta
--popover Superficie flotante <Popover>, <DropdownMenu>
--popover-foreground Texto en popovers Contenido de popover
--primary Interactivo principal <Button variant="default">
--primary-foreground Texto sobre primary Etiqueta de botón
--secondary Superficie secundaria <Button variant="secondary">
--secondary-foreground Texto sobre secondary Etiqueta de botón secundario
--muted Fondo atenuado <Badge>, estados deshabilitados
--muted-foreground Texto atenuado Metadatos, marcadores de posición
--accent Estado hover/seleccionado Hover de ítem en menús
--accent-foreground Texto sobre accent Etiqueta de ítem con hover
--destructive Peligro/error <Button variant="destructive">
--destructive-foreground Texto sobre destructive Etiqueta de botón destructivo
--border Borde predeterminado Divisores, contornos de input
--input Borde de campo de entrada <Input>, <Select>
--ring Anillo de enfoque Indicador de foco de teclado

Creación de Temas Personalizados

Enfoque 1: Usar el Constructor de Temas Oficial

El sitio web de shadcn/ui incluye un constructor de temas en ui.shadcn.com/themes. Eliges un tono de color base y una escala de grises, y genera el bloque CSS completo para los modos claro y oscuro. Este es el enfoque más rápido para cambios simples de color de marca.

Enfoque 2: Construir a Partir de un Color de Marca

Para un control completo, comienza con el color primario de tu marca y deriva manualmente el conjunto completo de variables.

Supongamos que tu primary de marca es #6D28D9 (un violeta profundo). Conviértelo a HSL usando el Convertidor de Color: aproximadamente hsl(263, 70%, 50%). Los canales HSL que necesitas son 263 70% 50%.

A partir de ahí, deriva la paleta completa:

@layer base {
  :root {
    /* Variables derivadas de la marca */
    --background: 0 0% 100%;
    --foreground: 263 20% 8%;           /* casi negro con toque violeta */

    --card: 0 0% 100%;
    --card-foreground: 263 20% 8%;

    --popover: 0 0% 100%;
    --popover-foreground: 263 20% 8%;

    /* Primary: tu violeta de marca */
    --primary: 263 70% 50%;
    --primary-foreground: 263 10% 98%; /* casi blanco */

    /* Secondary: un tinte violeta claro */
    --secondary: 263 30% 94%;
    --secondary-foreground: 263 70% 30%;

    /* Muted: secondary desaturado */
    --muted: 260 20% 95%;
    --muted-foreground: 260 15% 50%;

    /* Accent: estado hover (ligeramente más saturado que secondary) */
    --accent: 263 40% 90%;
    --accent-foreground: 263 70% 30%;

    /* Destructive: rojo estándar */
    --destructive: 0 84% 60%;
    --destructive-foreground: 0 0% 98%;

    /* Bordes y controles */
    --border: 263 20% 88%;
    --input:  263 20% 88%;
    --ring:   263 70% 50%;   /* coincide con primary — el anillo de foco refleja la marca */

    --radius: 0.5rem;
  }
}

Consejos para Derivar Cada Rol

--primary-foreground: El primer plano sobre tu botón primary debe cumplir WCAG AA (4.5:1) respecto a --primary. Para colores muy saturados, el casi-blanco (luminosidad del 98%) generalmente funciona. Verifica con el Verificador de Contraste.

--secondary y --muted: Típicamente son el mismo tono que --primary pero con la saturación reducida al 20–35% y la luminosidad por encima del 90%. Crean cohesión visual sin competir con el primary.

--accent: Es el fondo hover para ítems de lista interactivos (opciones de combobox, ítems de menú desplegable). Debe ser visible pero no chocante —una luminosidad del 88–92% con saturación moderada funciona para la mayoría de tonos.

--ring: El anillo de foco de teclado debe coincidir exactamente con tu color primary, para que los usuarios de teclado vean el mismo color de marca que los usuarios de ratón ven en los fondos de botón. La accesibilidad requiere que el anillo sea visible: contraste de 3:1 respecto al fondo adyacente.

--destructive: No lo cambies para que coincida con tu marca a menos que tu marca sea roja. Los usuarios tienen una expectativa profundamente condicionada de que las acciones destructivas son rojas. Mantenlo en el rango de tono 0–10°.

Integración del Modo Oscuro

shadcn/ui usa la clase .dark en <html> (no [data-theme="dark"]) como selector de modo oscuro. Esto coincide con la configuración darkMode: 'class' de Tailwind CSS.

Definición de Valores del Modo Oscuro

El bloque del modo oscuro sigue la misma estructura pero invierte las relaciones de luminosidad:

.dark {
  --background: 263 25% 7%;            /* gris-violeta muy oscuro */
  --foreground: 263 10% 92%;

  --card: 263 25% 10%;
  --card-foreground: 263 10% 92%;

  --popover: 263 25% 10%;
  --popover-foreground: 263 10% 92%;

  /* Primary: mismo tono, tono más claro para fondo oscuro */
  --primary: 263 70% 70%;             /* era 50% en modo claro, ahora 70% de luminosidad */
  --primary-foreground: 263 30% 8%;   /* texto oscuro sobre botón claro */

  --secondary: 263 25% 18%;
  --secondary-foreground: 263 70% 85%;

  --muted: 263 20% 15%;
  --muted-foreground: 263 15% 55%;

  --accent: 263 30% 22%;
  --accent-foreground: 263 70% 85%;

  --destructive: 0 62% 50%;
  --destructive-foreground: 0 0% 98%;

  --border: 263 20% 20%;
  --input:  263 20% 20%;
  --ring:   263 70% 70%;              /* coincide con primary */
}

El ajuste clave es --primary: en modo claro tenía luminosidad del 50% (saturado, contrasta sobre blanco). En modo oscuro se convierte en luminosidad del 70% —más claro y aún saturado, que contrasta con el --background casi negro. Un color primary que supera el contraste en modo claro casi con seguridad fallará en modo oscuro sin este ajuste.

Usa el Generador de Tonos para explorar rápidamente tu tono en todos los niveles de luminosidad. Introduce #6D28D9 y examina el rango 200–400 para tonos primary adecuados para el modo oscuro.

Integración con el Toggle de JavaScript

El modo oscuro de shadcn/ui funciona con cualquier mecanismo que añada/elimine la clase .dark en <html>. La integración más habitual en proyectos Next.js usa next-themes:

npm install next-themes
// app/providers.tsx
import { ThemeProvider } from 'next-themes'

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider
      attribute="class"          // añade la clase 'dark' a <html>
      defaultTheme="system"      // respetar la preferencia del SO
      enableSystem               // permitir detección de preferencia del sistema
    >
      {children}
    </ThemeProvider>
  )
}
// components/theme-toggle.tsx
import { useTheme } from 'next-themes'

export function ThemeToggle() {
  const { theme, setTheme } = useTheme()
  return (
    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
      Cambiar tema
    </button>
  )
}

Para proyectos Vite/React puro sin next-themes, gestiona la clase directamente con localStorage:

// utils/theme.ts
export function getTheme(): 'light' | 'dark' {
  const stored = localStorage.getItem('theme') as 'light' | 'dark' | null
  if (stored) return stored
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}

export function applyTheme(theme: 'light' | 'dark') {
  document.documentElement.classList.toggle('dark', theme === 'dark')
  localStorage.setItem('theme', theme)
}

Llama a applyTheme(getTheme()) en <head> antes del primer renderizado para evitar el parpadeo.

Sincronizar Temas de shadcn con la Configuración de Tailwind

Los componentes de shadcn/ui usan clases de utilidad de Tailwind que referencian las variables CSS —bg-background, text-foreground, border-border, y así sucesivamente. Estas utilidades solo existen porque shadcn/ui las configura en tailwind.config.js o tailwind.config.ts:

// tailwind.config.ts — generado por shadcn init
import type { Config } from 'tailwindcss'

const config: Config = {
  darkMode: ['class'],
  content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: {
        background:    'hsl(var(--background))',
        foreground:    'hsl(var(--foreground))',
        card: {
          DEFAULT:     'hsl(var(--card))',
          foreground:  'hsl(var(--card-foreground))',
        },
        primary: {
          DEFAULT:     'hsl(var(--primary))',
          foreground:  'hsl(var(--primary-foreground))',
        },
        // ... etc
        destructive: {
          DEFAULT:     'hsl(var(--destructive))',
          foreground:  'hsl(var(--destructive-foreground))',
        },
        border:   'hsl(var(--border))',
        input:    'hsl(var(--input))',
        ring:     'hsl(var(--ring))',
      },
    },
  },
}

Esta configuración vincula el sistema de clases de utilidad de Tailwind a las variables CSS. bg-primary genera background-color: hsl(var(--primary)), que a su vez lee los canales HSL establecidos para --primary en CSS. El color completo no está codificado directamente en el CSS generado por Tailwind —se resuelve en tiempo de ejecución a partir de la variable.

Añadir Colores de Escala de Marca Junto a los Tokens Semánticos

Un requisito habitual es usar la escala completa de tonos de tu marca en componentes (p.ej., text-brand-700) mientras también se tienen las utilidades semánticas bg-primary. Estos coexisten sin conflicto:

// tailwind.config.ts
const config: Config = {
  theme: {
    extend: {
      colors: {
        // Tokens semánticos (shadcn/ui)
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        primary: {
          DEFAULT: 'hsl(var(--primary))',
          foreground: 'hsl(var(--primary-foreground))',
        },
        // Tokens de escala (marca)
        brand: {
          50:  '#F5F3FF',
          100: '#EDE9FE',
          200: '#DDD6FE',
          300: '#C4B5FD',
          400: '#A78BFA',
          500: '#8B5CF6',
          600: '#7C3AED',
          700: '#6D28D9',
          800: '#5B21B6',
          900: '#4C1D95',
          950: '#2E1065',
        },
      },
    },
  },
}

Con esta configuración, obtienes tanto bg-primary (dinámico, consciente del tema) como bg-brand-700 (#6D28D9, estático). Usa el token semántico para los valores predeterminados de los componentes y el token de escala para sobreescrituras puntuales donde necesites un tono específico independientemente del tema actual.

Compatibilidad con Tailwind v4

Si adoptas Tailwind v4 con shadcn/ui, la configuración se mueve a @theme en CSS:

@import "tailwindcss";

@theme {
  --color-background:        hsl(var(--background));
  --color-foreground:        hsl(var(--foreground));
  --color-primary:           hsl(var(--primary));
  --color-primary-foreground: hsl(var(--primary-foreground));
  /* ... etc */
}

Nótese la doble indirección de variables: --color-primary (convención de nomenclatura de propiedades personalizadas CSS de Tailwind) referencia hsl(var(--primary)) (la variable de shadcn/ui). Esto es ligeramente incómodo pero completamente funcional y necesario para conectar ambos sistemas en v4.

Conclusiones Clave

  • El sistema de colores de shadcn/ui usa variables CSS de solo canales HSL (p.ej., 222.2 47.4% 11.2%) en lugar de valores hsl() completos. Esto permite opacidad composable: hsl(var(--primary) / 0.5).
  • Los 16 roles de color semántico (--background, --primary, --destructive, etc.) desacoplan el marcado de los componentes de valores de color específicos —tematizas el sistema cambiando los valores de las variables, no el código de los componentes.
  • Para temas personalizados, comienza con el hex primary de tu marca, conviértelo a HSL con el Convertidor de Color y luego deriva los roles secondary y muted reduciendo la saturación y ajustando la luminosidad.
  • El modo oscuro requiere atención específica a --primary: un tono con luminosidad del 50% que funciona sobre fondos blancos necesita moverse al 65–75% de luminosidad para mantener el contraste sobre fondos oscuros.
  • La configuración de Tailwind mapea las clases de utilidad (bg-primary, text-foreground) a consultas de variables CSS (hsl(var(--primary))), vinculando ambos sistemas. Los colores basados en escala pueden coexistir con los tokens semánticos en la misma configuración.
  • Usa el Generador de Tonos para producir una escala completa de marca para casos de uso con tonos exactos, y el Generador de Paletas para explorar opciones de color secundario complementarias.

Colores relacionados

Marcas relacionadas

Herramientas relacionadas