Inicio / Artículos

Propiedades personalizadas de CSS en tiempo de ejecución: temas, puentes con JavaScript y @property

Aprende cómo las propiedades personalizadas de CSS difieren de las variables de Sass y úsalas para la creación de temas, efectos controlados por JavaScript, tipografía fluida, variantes de componentes y animaciones con tipos definidos.

Editor: REACTAPP.TOP17 min de lectura3986 palabras

Fuentes: CSS Variables Are Underrated — Here’s What You’re Missing

Muchos conjuntos de código utilizan propiedades personalizadas de CSS de manera similar a las variables de Sass: una lista de colores declarados una sola vez en :root y que nunca se modifican posteriormente. Eso funciona, pero ignora la característica que los diferencia. Las propiedades personalizadas son valores en tiempo real en el navegador que se heredan a través del DOM, responden a las consultas de medios y a los cambios en atributos, y pueden actualizarse desde JavaScript en cualquier momento. Esta guía muestra qué permite este comportamiento en tiempo de ejecución en la práctica, desde temas y componentes parametrizados hasta animaciones tipadas con @property, y señala las mecánicas y dificultades que causan problemas a los equipos.

Constantes en tiempo de compilación versus valores en tiempo real

Los preprocesadores como Sass, Less y Stylus han dado forma al estilo de las interfaces durante años mediante anidamiento, mixins y variables. Cuando llegaron las propiedades personalizadas nativas a los navegadores, una reacción común fue pensar que no añadían nada que $primary-color ya no ofreciera. Esa suposición es la causa de su bajo uso. La diferencia radica en cuándo existe el valor:

+-------------------------------------------------------------------+
|                     SASS / SCSS VARIABLES                         |
|  Compile-Time Constants  -->  Evaluated Before Ship  -->  Static   |
+-------------------------------------------------------------------+

+-------------------------------------------------------------------+
|                    CSS CUSTOM PROPERTIES                          |
|  Runtime Live Properties --> Evaluated in Browser --> Dynamic     |
+-------------------------------------------------------------------+

En qué se convierte una variable de preprocesador

Una variable de Sass se resuelve antes incluso de que el CSS llegue al navegador. En el siguiente SCSS, se asigna un segundo valor dentro del bloque modificador dark:

$brand-color: #3b82f6;

.card {
  background-color: $brand-color;
}

.card--dark {
  $brand-color: #1e293b;
  background-color: $brand-color;
}

El compilador sustituye el valor literal en todos los lugares donde se utiliza la variable, por lo que la hoja de estilo final es simplemente esta:

.card {
  background-color: #3b82f6;
}

.card--dark {
  background-color: #1e293b;
}

Después de la compilación, el nombre $brand-color ya no existe. El navegador no puede saber que ambos colores provienen de una misma variable, ni puede modificar ese valor en respuesta a un clic, un cambio de vista o un estado de hover. Cada variación debe generarse como CSS adicional con antelación.

Qué permanece de una propiedad personalizada

La versión nativa mantiene el nombre activo en el navegador:

:root {
  --brand-color: #3b82f6;
}

.card {
  background-color: var(--brand-color);
}

.card[data-theme="dark"] {
  --brand-color: #1e293b;
}

Aquí --brand-color forma parte del estilo calculado de cada elemento. Cuando un .card recibe data-theme="dark", el navegador vuelve a calcular var(--brand-color) para ese card y sus descendientes. La regla de .card no se duplica; solo se modifica el valor para una rama del árbol DOM. En resumen: las variables de Sass se reemplazan durante la compilación y desaparecen en tiempo de ejecución, mientras que las propiedades personalizadas son resueltas por el navegador para cada elemento, participan en la cascada y pueden cambiar después de que se cargue la página.

Las mecánicas que debe entender bien

Algunas reglas sobre sintaxis, ámbito y manejo de errores explican la mayor parte del comportamiento sorprendente con el que se encuentran las personas.

Nomenclatura y lectura de valores

El nombre de una propiedad personalizada debe comenzar con dos guiones, y los nombres son sensibles a mayúsculas y minúsculas, por lo que estas son dos propiedades separadas:

:root {
  --main-color: #10b981;
  --Main-Color: #ef4444; /* Different property! */
}

Lees un valor con la función var():

.button {
  background-color: var(--main-color);
}

El ámbito sigue la cascada

Las propiedades personalizadas obedecen la cascada e herencia normales. Al declarar una en :root, que coincide con el elemento <html>, queda disponible en todas partes. Al declararla en otro selector, solo se sobrescribe para ese elemento y sus descendientes:

/* Global Scope */
:root {
  --spacing-unit: 1rem;
}

/* Sub-tree Scope Override */
.sidebar {
  --spacing-unit: 0.5rem; /* Overrides --spacing-unit for .sidebar and its descendants */
}

.sidebar .widget {
  padding: var(--spacing-unit); /* Computes to 0.5rem */
}

.main-content .widget {
  padding: var(--spacing-unit); /* Computes to 1rem */
}

Ambos widgets utilizan la misma regla, pero el que está dentro de .sidebar tiene un espaciado menor porque gana la declaración más cercana. Esto es lo que hace que las sobrescripciones por ámbito sean tan sencillas: se cambia un valor, no una regla.

Valores de fallback

var() acepta un segundo argumento que se utiliza cuando la propiedad referenciada no tiene valor. Los valores de fallback pueden contener ellos mismos llamadas a var(), por lo que se pueden encadenar:

.avatar {
  /* Fallback to standard 40px if --avatar-size is undefined */
  width: var(--avatar-size, 40px);

  /* Nested fallbacks */
  color: var(--user-theme-color, var(--brand-default, #2563eb));
}

Sea preciso respecto al momento en que se aplica la solución de respaldo. Se utiliza únicamente cuando la propiedad personalizada no está definida para ese elemento (ni se ha heredado de un ancestro) o cuando se establece explícitamente en initial. Si la propiedad está definida pero contiene un valor que no tiene sentido para el destino, como una longitud cuando se espera un color, la solución de respaldo se ignora. Tenga en cuenta que unset no es una forma fiable de activarla, ya que las propiedades personalizadas se heredan por defecto; por lo tanto, unset funciona como inherit y toma el valor del padre si existe.

Inválido en el momento de calcular el valor

Una declaración ordinaria con un valor inválido se descarta en el momento del análisis, por lo que sigue aplicándose una declaración válida anterior. Las declaraciones que contienen var() no pueden verificarse durante el análisis, ya que el valor sustituido es desconocido hasta que se ejecuta la cascada:

.box {
  color: red;
  color: var(--not-a-color, 12px); /* Invalid type for color! */
}

El analizador acepta la segunda línea color y sobrescribe color: red. Solo en el momento de calcular el valor, el navegador descubre que 12px no es un color. Para entonces, la declaración anterior ya ha perdido validez, por lo que la propiedad se comporta como si estuviera unset: las propiedades heredadas como color toman el valor del padre, mientras que las no heredadas vuelven a su valor inicial (por ejemplo, transparent para background-color). Este fallo silencioso es uno de los problemas que @property, abordado más adelante, está diseñado para resolver.

Tematización sin reglas duplicadas

Una implementación clásica de modo oscuro duplica cada regla temática:

/* Traditional approach: Duplicating declarations */
.card {
  background-color: #ffffff;
  color: #1e293b;
  border: 1px solid #e2e8f0;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
}

body.dark-mode .card {
  background-color: #0f172a;
  color: #f8fafc;
  border: 1px solid #334155;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.5);
}

Con unas pocas propiedades esto es manejable. Con docenas de componentes, cada uno con muchas declaraciones temáticas, cada tema multiplica los selectores que hay que mantener sincronizados.

Los tokens hacen que los componentes sean independientes de la temática

Si las decisiones de diseño se encuentran en propiedades personalizadas, las reglas de los componentes ya no dependen de qué tema esté activo:

/* 1. Define Design Tokens */
:root {
  --bg-surface: #ffffff;
  --text-primary: #1e293b;
  --border-subtle: #e2e8f0;
  --shadow-alpha: 0.05;
}

[data-theme="dark"] {
  --bg-surface: #0f172a;
  --text-primary: #f8fafc;
  --border-subtle: #334155;
  --shadow-alpha: 0.5;
}

/* 2. Write Component CSS Once */
.card {
  background-color: var(--bg-surface);
  color: var(--text-primary);
  border: 1px solid var(--border-subtle);
  box-shadow: 0 4px 6px rgba(0, 0, 0, var(--shadow-alpha));
}

La regla .card existe una sola vez. Al agregar un tercer tema, como [data-theme="high-contrast"], es necesario proporcionar cuatro valores de token en lugar de volver a declarar reglas para cada componente.

Derivar una paleta a partir de canales de color

Puedes ir más allá almacenando los canales individuales de un color, por ejemplo en OKLCH, y calcular todas las variaciones a partir de ellos:

:root {
  /* Store raw OKLCH channels: Lightness, Chromaticity, Hue */
  --brand-l: 0.62;
  --brand-c: 0.19;
  --brand-h: 255;

  /* Base dynamic color */
  --brand-color: oklch(var(--brand-l) var(--brand-c) var(--brand-h));

  /* Automatically derived variations */
  --brand-hover: oklch(calc(var(--brand-l) - 0.1) var(--brand-c) var(--brand-h));
  --brand-active: oklch(calc(var(--brand-l) - 0.2) var(--brand-c) var(--brand-h));
  --brand-tint: oklch(0.95 calc(var(--brand-c) * 0.2) var(--brand-h));
  --brand-alpha-50: oklch(var(--brand-l) var(--brand-c) var(--brand-h) / 0.5);
}

.button-primary {
  background-color: var(--brand-color);
  color: white;
}

.button-primary:hover {
  background-color: var(--brand-hover);
}

.button-primary:active {
  background-color: var(--brand-active);
}

.badge {
  background-color: var(--brand-tint);
  color: var(--brand-color);
}

Las variantes de hover, active, tint y transparente son todas expresiones que dependen de tres números. Al cambiar --brand-h en :root, ya sea desde JavaScript o según las preferencias del usuario, se vuelve a colorear el color base, sus estados, el tint y las superposiciones de manera consistente, sin necesidad de una biblioteca de manipulación de colores.

Existe una sutileza relacionada con cada token derivado: un var() dentro de una propiedad personalizada se resuelve en el elemento donde dicha propiedad está declarada, y los descendientes heredan el valor resultante. Dado que --brand-color y otros valores similares se declaran en :root, sobrescribir --brand-h en niveles más profundos del árbol no vuelve a calcular esos valores allí. Es necesario cambiar el canal en :root o volver a declarar los tokens derivados en el ámbito donde se sobrescribe el canal.

Controlar CSS desde JavaScript

Las propiedades personalizadas constituyen una interfaz sencilla entre los scripts y los estilos. En lugar de escribir cadenas de estilo en línea o lidiar con combinaciones de clases, JavaScript puede pasar valores brutos, como coordenadas del puntero o el estado de la aplicación, a CSS y dejar que la hoja de estilos decida qué hacer con ellos.

Resaltado que sigue al cursor

Comience con un elemento card:

<div class="glow-card" id="card">
  <h3>Interactive Reactivity</h3>
  <p>Hover over this card to see real-time cursor lighting.</p>
</div>

La hoja de estilos declara coordenadas por defecto y las utiliza para posicionar un gradiente radial en un pseudo-elemento:

.glow-card {
  --mouse-x: 50%;
  --mouse-y: 50%;

  position: relative;
  background-color: #1e1e2e;
  border-radius: 12px;
  padding: 2rem;
  color: #cdd6f4;
  overflow: hidden;
}

/* Radial light overlay following cursor coordinates */
.glow-card::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(
    800px circle at var(--mouse-x) var(--mouse-y),
    rgba(255, 255, 255, 0.08),
    transparent 40%
  );
  pointer-events: none;
}

El script solo indica dónde se encuentra el puntero, en relación con la tarjeta:

const card = document.getElementById('card');

card.addEventListener('pointermove', (e) => {
  const rect = card.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;

  // Send raw coordinates to CSS
  card.style.setProperty('--mouse-x', `${x}px`);
  card.style.setProperty('--mouse-y', `${y}px`);
});

Este reparto del trabajo es lo que hace atractivo el método: JavaScript escribe dos números en un elemento y nunca modifica el degradado en sí. Sin embargo, no es gratuito. Cada actualización desencadena un nuevo cálculo de estilos para la tarjeta, y como el degradado cambia, la capa superpuesta se vuelve a pintar. Ese costo es pequeño para un único elemento, por lo que el efecto suele funcionar sin problemas, pero no se trata de un proceso de “reflow cero” que ocurre fuera del hilo principal.

Una barra de progreso de desplazamiento

La misma idea permite pasar la posición de desplazamiento a CSS como una fracción sin unidades entre 0 y 1:

window.addEventListener('scroll', () => {
  const scrollTop = window.scrollY;
  const docHeight = document.documentElement.scrollHeight - window.innerHeight;
  const scrollPercent = (scrollTop / docHeight);

  // Set unitless fraction (0 to 1)
  document.documentElement.style.setProperty('--scroll-progress', scrollPercent);
});

La barra la consume mediante transform, lo que evita tareas de diseño, ya que el escalado no modifica el tamaño de nada más en la página:

/* Progress indicator bar */
.scroll-progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  width: 100%;
  background-color: #3b82f6;
  transform-origin: left;
  /* Hardware-accelerated GPU transform using variable */
  transform: scaleX(var(--scroll-progress, 0));
  will-change: transform;
}

Hay dos mejoras que vale la pena considerar. El script escribe en document.documentElement, lo cual representa exactamente esa actualización global y frecuente contra la que advierte la sección de rendimiento; establecer el valor directamente en la barra limita los recálculos a un solo elemento. Además, en navegadores que soportan animaciones controladas por desplazamiento CSS, se puede crear una barra de progreso como esta con animation-timeline: scroll() sin necesidad de JavaScript alguno.

Tipografía fluida y relaciones de escala

La tipografía basada en puntos de ruptura cambia entre tamaños fijos en anchuras como 768px y 1024px. En cambio, combinar propiedades personalizadas con clamp() y calc() permite que los tamaños cambien de forma continua entre un valor mínimo y uno máximo.

Cálculo parametrizado de clamp()

La idea es describir la escala con números simples (rango de visor y rango de tamaño de fuente) y determinar la pendiente e intersección de una función lineal entre ellos:

:root {
  /* Viewport and layout parameters */
  --fluid-min-width: 320;
  --fluid-max-width: 1200;

  /* Typography scale parameters */
  --font-min-size: 16;
  --font-max-size: 20;

  /* Compute slope and intersection for clamp() */
  --slope: (var(--font-max-size) - var(--font-min-size)) / (var(--fluid-max-width) - var(--fluid-min-width));
  --y-axis-intersection: -1 * var(--fluid-min-width) * var(--slope) + var(--font-min-size);

  /* Fluid base font size expression */
  --fluid-type-base: clamp(
    calc(var(--font-min-size) * 1px),
    calc(var(--y-axis-intersection) * 1px + (var(--slope) * 100vw)),
    calc(var(--font-max-size) * 1px)
  );
}

body {
  font-size: var(--fluid-type-base);
}

Considere este fragmento como un punto de partida y no como código funcional. La sustitución de propiedades personalizadas es textual, por lo que cuando --y-axis-intersection se inserta en calc(var(--y-axis-intersection) * 1px + ...), el * 1px solo se aplica al último término de esa expresión. El primer término, que no tiene unidades, se suma entonces a una longitud, lo que hace que toda la función clamp() sea inválida. Rodear las expresiones intermedias entre paréntesis, por ejemplo --y-axis-intersection: (-1 * var(--fluid-min-width) * var(--slope) + var(--font-min-size));, o dentro de calc(), mantiene el agrupamiento deseado.

Tamaños de encabezados armónicos a partir de una relación

Con un tamaño base establecido, una sola relación puede generar toda la escala de encabezados:

:root {
  /* Scale multiplier ratio (e.g., 1.25 Major Third) */
  --type-ratio: 1.25;

  --step-0: var(--fluid-type-base);
  --step-1: calc(var(--step-0) * var(--type-ratio));
  --step-2: calc(var(--step-1) * var(--type-ratio));
  --step-3: calc(var(--step-2) * var(--type-ratio));
  --step-4: calc(var(--step-3) * var(--type-ratio));
}

/* Application across elements */
p  { font-size: var(--step-0); }
h3 { font-size: var(--step-1); }
h2 { font-size: var(--step-2); }
h1 { font-size: var(--step-3); }

Cada paso multiplica al anterior, por lo que las proporciones entre los niveles se mantienen consistentes. Al cambiar la relación dentro de una consulta media, se recalculan todos los niveles al mismo tiempo:

@media (min-width: 1024px) {
  :root {
    /* Upgrade to 1.333 Perfect Fourth on large displays */
    --type-ratio: 1.333;
  }
}

Esto funciona porque los pasos y la relación se declaran en el mismo elemento, :root, por lo que la modificación es visible cuando se calculan los pasos.

Componentes con parámetros en lugar de clases variantes

Las clases variantes en metodologías como BEM tienden a multiplicarse: cada tamaño, forma y color se convierte en otro bloque de declaraciones que repite parcialmente la base.

/* Traditional class bloat */
.btn { padding: 8px 16px; font-size: 14px; background: blue; border-radius: 4px; }
.btn--md { padding: 12px 24px; font-size: 16px; }
.btn--lg { padding: 16px 32px; font-size: 18px; }
.btn--rounded { border-radius: 9999px; }
.btn--danger { background: red; }
.btn--success { background: green; }

Propiedades personalizadas como API de un componente

Un enfoque más robusto trata las propiedades personalizadas como la configuración del componente. La regla básica lee cada valor ajustable de una propiedad con un valor por defecto, y los modificadores solo cambian los parámetros:

.btn {
  /* 1. Component Defaults */
  --btn-bg: #3b82f6;
  --btn-color: #ffffff;
  --btn-px: 1.25rem;
  --btn-py: 0.625rem;
  --btn-radius: 0.375rem;
  --btn-font-size: 0.875rem;

  /* 2. Unified Rule Declaration */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--btn-py) var(--btn-px);
  background-color: var(--btn-bg);
  color: var(--btn-color);
  border-radius: var(--btn-radius);
  font-size: var(--btn-font-size);
  border: none;
  cursor: pointer;
  transition: background-color 0.2s, transform 0.1s;
}

.btn:hover {
  filter: brightness(0.9);
}

/* 3. Concise Declarative Modifiers */
.btn--lg {
  --btn-px: 1.75rem;
  --btn-py: 0.875rem;
  --btn-font-size: 1.125rem;
}

.btn--pill {
  --btn-radius: 9999px;
}

.btn--danger {
  --btn-bg: #ef4444;
}

.btn--ghost {
  --btn-bg: transparent;
  --btn-color: #3b82f6;
}

Los modificadores como .btn--lg no repiten valores de display, border-radius, cursor o transition. Solo establecen nuevos valores para esos parámetros. El estilo se mantiene compacto, los modificadores se combinan de forma natural (btn--lg btn--pill btn--danger funciona sin problemas), y cuando una variante no se ve bien, sabes que solo puede deberse a uno de unos pocos modificadores.

Propiedades tipadas y animaciones con @property

Las propiedades personalizadas no registradas presentan una limitación bien conocida: no se pueden intercalar en transiciones ni fotogramas clave. El navegador trata sus valores como secuencias de tokens sin tipo, por lo que no puede animar desde 0deg hasta 360deg ni desde red hasta blue; el valor simplemente cambia en algún momento durante la animación.

La regla @property registra una propiedad personalizada con una sintaxis, un valor inicial y una configuración de herencia. Está soportada en las versiones actuales de los principales navegadores, pero verifique sus objetivos de compatibilidad antes de depender de ella.

Registro de propiedades con tipo

/* Register type-checked custom property */
@property --gradient-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

@property --accent-color {
  syntax: "<color>";
  inherits: true;
  initial-value: #3b82f6;
}

El registro también soluciona el problema de fallos silenciosos mencionado anteriormente: un valor que no coincide con la sintaxis declarada es rechazado, y la propiedad recurre a su valor inicial registrado en lugar de uno heredado inesperado.

Transiciones en gradientes

Una vez que el navegador sabe que --gradient-angle es un ángulo y --accent-color es un color, puede interpolarlos, lo que hace posible una transición de gradiente:

.animated-gradient-card {
  /* Reference the typed property inside radial/linear gradient */
  background: linear-gradient(
    var(--gradient-angle),
    #0f172a,
    var(--accent-color),
    #0f172a
  );

  border-radius: 16px;
  padding: 3rem;

  /* Animate custom property on hover! */
  transition: --gradient-angle 0.8s ease, --accent-color 0.5s ease;
}

.animated-gradient-card:hover {
  --gradient-angle: 180deg;
  --accent-color: #ec4899;
}

Animación continua con fotogramas clave

Esa misma configuración permite utilizar fotogramas clave para hacer rotar un gradiente de forma indefinida:

@keyframes spin-gradient {
  to {
    --gradient-angle: 360deg;
  }
}

.glowing-border {
  animation: spin-gradient 4s linear infinite;
}

Tenga en cuenta que animar una propiedad registrada utilizada en un gradiente sigue haciendo que el elemento se vuelva a dibujar en cada frame en el hilo principal; no es lo mismo que una animación de transform o opacity que solo afecta al compositor.

Rendimiento: Donde perjudican las actualizaciones

Las propiedades personalizadas no son lentas por sí mismas. El costo depende de cuánta parte del árbol se invalida con la actualización.

Hasta dónde llega una actualización

Llamar a element.style.setProperty('--name', value) provoca la invalidación del estilo para ese elemento y para todo lo que pueda heredarlo:

+-------------------------------------------------------------------------+
|                  STYLE INVALIDATION CASCADE PATTERN                     |
|                                                                         |
|  [ :root Variable Mutation ]                                            |
|       └─> Invalidates Entire DOM Tree                                   |
|            └─> High Recalculation Cost (Large DOMs)                     |
|                                                                         |
|  [ Scoped Local Element Mutation ]                                      |
|       └─> Invalidates Only Local Component Branch                       |
|            └─> Minimal Engine Cost (60 FPS Safe)                        |
+-------------------------------------------------------------------------+
  • Cambiar una propiedad en :root puede obligar al navegador a volver a calcular los estilos de cada elemento que dependa de ella en cualquier parte del documento. Hacerlo con cada evento scroll o mousemove en una página grande puede afectar negativamente el rendimiento.
  • Establecer la propiedad en un elemento específico, como la tarjeta sobre la que se pasa el cursor, limita el recálculo únicamente a ese elemento.

Pautas prácticas

En primer lugar, envíe actualizaciones de alta frecuencia, como las posiciones del puntero o el progreso del desplazamiento, al componente que las utiliza, y no a :root.

En segundo lugar, para los valores que ningún descendiente necesita, regístrelos con inherits: false, lo cual limita hasta dónde se propagan los cambios durante la animación:

@property --local-x {
  syntax: "<length>";
  inherits: false; /* Performance booster! Stops inheritance traversal */
  initial-value: 0px;
}

Hay un detalle importante: los pseudo-elementos heredan de su elemento original, por lo que una propiedad registrada que no se hereda y se establece en un elemento no será visible dentro de ::before o ::after de dicho elemento. Si un pseudo-elemento lee ese valor, mantenga la propiedad heredada o aplique el efecto directamente en el elemento mismo.

En tercer lugar, cuando un valor se utilice para generar movimiento, consúmalo a través de transform o opacity para que el cambio evite la reorganización del layout y, cuando sea posible, también la pintura:

/* Performant GPU layer usage */
.box {
  transform: translate3d(var(--x, 0), var(--y, 0), 0);
}

Combinándolo todo: una tarjeta de panel de control temática

El widget siguiente combina varias técnicas: dos temas, un brillo que sigue al cursor, un control de tono que vuelve a colorear el componente y tamaños de encabezados fluidos. La estructura es un <article> semántico con un encabezado, un botón de alternancia y un campo de rango:

<article class="dash-card" id="dashCard" data-theme="dark">
  <header class="dash-card__header">
    <span class="dash-card__badge">Live Metrics</span>
    <button class="dash-card__theme-btn" id="themeToggle">Toggle Theme</button>
  </header>

  <div class="dash-card__body">
    <h2 class="dash-card__title">CSS Variable Architecture</h2>
    <p class="dash-card__text">
      Hover over this component to trigger lighting updates. Use the color slider below to adjust primary hue channels across all child components in real time.
    </p>

    <div class="dash-card__controls">
      <label for="hueSlider">Primary Hue: <span id="hueVal">220</span>°</label>
      <input type="range" id="hueSlider" min="0" max="360" value="220">
    </div>
  </div>
</article>

La hoja de estilo está organizada en tres capas: propiedades registradas y tokens globales, ámbitos de tema en la tarjeta, y reglas del componente que solo leen tokens:

/* ==========================================================================
   1. DESIGN TOKENS & SYSTEM SCALARS
   ========================================================================== */
@property --card-glow-x {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

@property --card-glow-y {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

:root {
  /* Default Color Channels */
  --hue: 220;
  --chroma: 0.18;

  /* Derived Dynamic Palette */
  --color-primary: oklch(0.60 var(--chroma) var(--hue));
  --color-primary-hover: oklch(0.50 var(--chroma) var(--hue));
  --color-primary-tint: oklch(0.95 calc(var(--chroma) * 0.3) var(--hue));

  /* Layout Dimensions */
  --radius-lg: 1rem;
  --radius-sm: 0.375rem;
}

/* ==========================================================================
   2. THEME SCOPES
   ========================================================================== */
.dash-card[data-theme="light"] {
  --bg-card: #ffffff;
  --bg-card-border: #e2e8f0;
  --text-main: #0f172a;
  --text-muted: #64748b;
  --glow-opacity: 0.12;
}

.dash-card[data-theme="dark"] {
  --bg-card: #0f172a;
  --bg-card-border: #1e293b;
  --text-main: #f8fafc;
  --text-muted: #94a3b8;
  --glow-opacity: 0.25;
}

/* ==========================================================================
   3. COMPONENT DECLARATION
   ========================================================================== */
.dash-card {
  position: relative;
  width: 100%;
  max-width: 480px;
  background-color: var(--bg-card);
  border: 1px solid var(--bg-card-border);
  border-radius: var(--radius-lg);
  padding: 2rem;
  color: var(--text-main);
  overflow: hidden;
  transition: background-color 0.3s, border-color 0.3s, color 0.3s;
  box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
}

/* Interactive Glow Accent Layer */
.dash-card::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(
    600px circle at var(--card-glow-x) var(--card-glow-y),
    oklch(0.70 var(--chroma) var(--hue) / var(--glow-opacity)),
    transparent 60%
  );
  pointer-events: none;
  transition: opacity 0.3s;
}

/* Header Elements */
.dash-card__header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 1.5rem;
}

.dash-card__badge {
  padding: 0.25rem 0.75rem;
  background-color: var(--color-primary-tint);
  color: var(--color-primary);
  border-radius: var(--radius-sm);
  font-size: 0.75rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.dash-card__theme-btn {
  background: transparent;
  border: 1px solid var(--bg-card-border);
  color: var(--text-main);
  padding: 0.5rem 0.875rem;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-size: 0.875rem;
  transition: background-color 0.2s;
}

.dash-card__theme-btn:hover {
  background-color: var(--color-primary-tint);
}

/* Content Elements */
.dash-card__title {
  font-size: clamp(1.25rem, 2vw + 1rem, 1.75rem);
  margin-bottom: 0.75rem;
  color: var(--text-main);
}

.dash-card__text {
  font-size: 0.95rem;
  line-height: 1.6;
  color: var(--text-muted);
  margin-bottom: 1.5rem;
}

.dash-card__controls {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  background: oklch(0.50 var(--chroma) var(--hue) / 0.05);
  padding: 1rem;
  border-radius: var(--radius-sm);
}

.dash-card__controls label {
  font-size: 0.875rem;
  font-weight: 600;
}

El script conecta los controles a esos tokens, escribiendo siempre directamente en la tarjeta y no en :root:

/* ==========================================================================
   4. REACTIVE SCRIPT BRIDGE
   ========================================================================== */
const card = document.getElementById('dashCard');
const themeBtn = document.getElementById('themeToggle');
const hueSlider = document.getElementById('hueSlider');
const hueVal = document.getElementById('hueVal');

// 1. Mouse coordinate tracking (Targeted scope prevents full layout invalidation)
card.addEventListener('pointermove', (e) => {
  const rect = card.getBoundingClientRect();
  card.style.setProperty('--card-glow-x', `${e.clientX - rect.left}px`);
  card.style.setProperty('--card-glow-y', `${e.clientY - rect.top}px`);
});

// 2. Interactive Theme Switcher
themeBtn.addEventListener('click', () => {
  const currentTheme = card.getAttribute('data-theme');
  const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
  card.setAttribute('data-theme', nextTheme);
});

// 3. Dynamic Color Engine Adjustment
hueSlider.addEventListener('input', (e) => {
  const newHue = e.target.value;
  hueVal.textContent = newHue;

  // Update hue variable locally on component container
  card.style.setProperty('--hue', newHue);
});

Antes de lanzar un componente como este, verifique dos detalles en el navegador, ya que ambos se derivan de la mecánica descrita anteriormente:

  • Las coordenadas del brillo se registran con inherits: false, pero se utilizan en .dash-card::before. Dado que el pseudo-elemento no las hereda, recibe los valores iniciales de 0px y la luz permanece en la esquina. Al registrarlas con inherits: true o dejarlas sin registrar, el efecto sigue al cursor.
  • El deslizador establece --hue en la tarjeta, pero --color-primary y --color-primary-tint se calculan en :root, por lo que la insignia mantiene su color original. Solo las reglas que utilizan directamente var(--hue) (el brillo y el fondo de los controles) responden. Redeclarar la paleta derivada en .dash-card hace que todo el componente siga al deslizador.

Puntos clave

  • Mueva los valores que varían entre temas y estados a propiedades personalizadas, y escriba cada regla de componente una sola vez.
  • Declare los tokens en el nivel al que pertenecen, ya sea :root, una región de diseño o un componente individual, y deje que la herencia los transmita hacia abajo. Recuerde que los tokens derivados se calculan en el lugar donde se declaran.
  • Deje que JavaScript transmita valores sin procesar mediante element.style.setProperty() en lugar de crear cadenas de estilo, y diríjase al elemento más pequeño que necesite ese valor.
  • Registre las propiedades con @property y una sintaxis como <color>, <angle> o <length> para obtener verificación de tipo e interpolación fluida.
  • Almacene los canales de color en HSL u OKLCH para obtener tonos, estados y transparencia sin necesidad de bibliotecas.
  • Mantenga las actualizaciones frecuentes lejos de :root; prefiera transform y opacity para los efectos de movimiento, y utilice inherits: false solo cuando ningún elemento descendiente o pseudo-elemento necesite ese valor.
  • Las propiedades personalizadas no son un sustituto de la sintaxis del preprocesador, sino más bien un modelo diferente: estilos que conocen el DOM, reaccionan a las interacciones y pueden ser controlados en tiempo de ejecución. Al usarlas de esta manera, se logran sistemas de diseño más compactos y fáciles de modificar.

    Lecturas relacionadas