Replicare le classi dinamiche di Tailwind CSS con JavaScript puro

Tailwind CSS genera classi utilitarie a partire dai nomi stessi delle classi. Quando scriviamo p-4, Tailwind produce padding: 1rem. Quando scriviamo rounded-[12px], produce border-radius: 12px. In questo tutorial costruiremo da zero, in JavaScript vanilla, un sistema che replica questo meccanismo: leggeremo le classi dal DOM, le tradurremo in proprieta e valori CSS, e le inietteremo come regole in un foglio di stile dinamico.

Il codice finale e racchiuso in una IIFE (Immediately Invoked Function Expression) in modo da non inquinare il contesto globale. Procederemo un pezzo alla volta, dal basso verso l'alto: prima le funzioni di utilita, poi la logica di estrazione, infine l'applicazione al DOM.

La mappa delle classi Tailwind verso le proprieta CSS

Il cuore del sistema e un oggetto che associa ogni prefisso di classe Tailwind alla corrispondente proprieta CSS. Alcune classi Tailwind come px, rounded-t o size impostano piu di una proprieta contemporaneamente: px-4 imposta sia padding-left che padding-right. Per gestire questi casi, il valore nella mappa e un array invece di una stringa semplice.

// Mappa che associa ogni prefisso di classe Tailwind
// alla corrispondente proprieta (o proprieta multiple) CSS.
// Quando il valore e un array, la classe imposta piu proprieta contemporaneamente.
const CLASS_TO_CSS_PROPERTY_MAP = {

  // --- Spaziatura ---
  m:    'margin',
  mx:   ['margin-left', 'margin-right'],
  my:   ['margin-top', 'margin-bottom'],
  mt:   'margin-top',
  mr:   'margin-right',
  mb:   'margin-bottom',
  ml:   'margin-left',
  ms:   'margin-inline-start',
  me:   'margin-inline-end',
  p:    'padding',
  px:   ['padding-left', 'padding-right'],
  py:   ['padding-top', 'padding-bottom'],
  pt:   'padding-top',
  pr:   'padding-right',
  pb:   'padding-bottom',
  pl:   'padding-left',
  ps:   'padding-inline-start',
  pe:   'padding-inline-end',

  // --- Dimensioni ---
  w:       'width',
  h:       'height',
  size:    ['width', 'height'],
  'min-w': 'min-width',
  'max-w': 'max-width',
  'min-h': 'min-height',
  'max-h': 'max-height',

  // --- Bordi ---
  border:     'border-width',
  'border-t': 'border-top-width',
  'border-r': 'border-right-width',
  'border-b': 'border-bottom-width',
  'border-l': 'border-left-width',
  'border-x': ['border-left-width', 'border-right-width'],
  'border-y': ['border-top-width', 'border-bottom-width'],
  rounded:      'border-radius',
  'rounded-t':  ['border-top-left-radius', 'border-top-right-radius'],
  'rounded-r':  ['border-top-right-radius', 'border-bottom-right-radius'],
  'rounded-b':  ['border-bottom-left-radius', 'border-bottom-right-radius'],
  'rounded-l':  ['border-top-left-radius', 'border-bottom-left-radius'],
  'rounded-tl': 'border-top-left-radius',
  'rounded-tr': 'border-top-right-radius',
  'rounded-bl': 'border-bottom-left-radius',
  'rounded-br': 'border-bottom-right-radius',

  // --- Sfondo ---
  bg: 'background',

  // --- Tipografia ---
  text:     'font-size',
  font:     'font-weight',
  tracking: 'letter-spacing',
  leading:  'line-height',
  indent:   'text-indent',

  // --- Layout ---
  gap:     'gap',
  'gap-x': 'column-gap',
  'gap-y': 'row-gap',
  basis:   'flex-basis',
  grow:    'flex-grow',
  shrink:  'flex-shrink',
  order:   'order',
  columns: 'columns',

  // --- Posizionamento ---
  top:       'top',
  right:     'right',
  bottom:    'bottom',
  left:      'left',
  inset:     'inset',
  'inset-x': ['left', 'right'],
  'inset-y': ['top', 'bottom'],
  z:         'z-index',

  // --- Effetti ---
  opacity: 'opacity',
  blur:    'filter',
  shadow:  'box-shadow',

  // --- Transizioni ---
  duration: 'transition-duration',
  delay:    'transition-delay',
  ease:     'transition-timing-function',

  // --- Trasformazioni ---
  scale:         ['scale-x', 'scale-y'],
  'scale-x':     'scale-x',
  'scale-y':     'scale-y',
  rotate:        'rotate',
  'translate-x': 'translate-x',
  'translate-y': 'translate-y',

  // --- Altro ---
  accent:           'accent-color',
  outline:          'outline-width',
  'outline-offset': 'outline-offset',
  ring:             'box-shadow',
  'ring-offset':    'box-shadow',
  caret:            'caret-color',
  stroke:           'stroke-width',
  fill:             'fill',
};

Questa mappa e il dizionario fondamentale di tutto il sistema. Ogni funzione successiva vi accede per tradurre i prefissi delle classi nelle proprieta CSS corrette. Nota come border sia mappato a border-width e non alla shorthand border: questo perche in Tailwind border-2 imposta solo lo spessore, e usare la shorthand resetterebbe anche colore e stile.

Estrarre il valore CSS da una classe

La funzione extractTWCSSValue riceve la porzione di valore di una classe Tailwind e la converte nel corrispondente valore CSS. Deve gestire tre casi principali: i valori arbitrari racchiusi tra parentesi quadre, i numeri puri da convertire in pixel, e i valori che hanno gia un'unita di misura.

function extractTWCSSValue(value = '') {
  if (!value) return '';

  const val = value.trim();

  // Valori arbitrari: il contenuto tra parentesi quadre viene estratto cosi com'e.
  // Gli underscore vengono sostituiti con spazi, seguendo la convenzione di Tailwind.
  // Esempio: [1fr_auto_1fr] diventa "1fr auto 1fr"
  if (val.startsWith('[') && val.endsWith(']')) {
    return val.slice(1, -1).replace(/_/g, ' ');
  }

  // Numeri puri: vengono interpretati come pixel.
  // Gestisce anche i decimali come 1.5 o 0.75.
  // Esempio: "4" diventa "4px", "1.5" diventa "1.5px"
  if (/^\d+(\.\d+)?$/.test(val)) {
    return `${val}px`;
  }

  // Numeri con unita di misura gia presente: restituiti senza modifiche.
  // Esempio: "1.5rem" resta "1.5rem", "100%" resta "100%"
  if (
    /^\d+(\.\d+)?(rem|em|%|vh|vw|dvh|svh|ch|ex|px|pt|cm|mm|in)$/.test(val)
  ) {
    return val;
  }

  // Qualsiasi altro valore viene restituito invariato.
  // Questo copre parole chiave come "auto", "full", "screen", eccetera.
  return val;
}

I punti chiave di questa funzione sono tre. Primo: i valori arbitrari ([...]) vengono controllati per primi, perche il loro contenuto potrebbe essere un numero puro che verrebbe intercettato erroneamente dal controllo successivo. Secondo: slice(1, -1) e preferito a replace('[', '') perche rimuove esattamente il primo e l'ultimo carattere, senza rischiare di toccare parentesi quadre che potrebbero comparire all'interno del valore (come in calc). Terzo: la sostituzione degli underscore con spazi replica il comportamento nativo di Tailwind, dove _ e l'alias per lo spazio nei valori arbitrari.

Estrarre la proprieta CSS da una classe

La funzione extractTWCSSProperty prende il prefisso di una classe e restituisce la proprieta CSS corrispondente. Alcune classi Tailwind cambiano semantica a seconda del valore: bg-4 imposta la dimensione dello sfondo, ma bg-#ff0000 imposta il colore. La funzione rileva quando il valore e un colore e aggiunge automaticamente il suffisso -color alla proprieta.

function extractTWCSSProperty(value = '', styleValue = '') {
  if (!value) return '';

  const val = value.trim();
  const entry = CLASS_TO_CSS_PROPERTY_MAP[val];

  // Se il prefisso non e nella mappa, non possiamo tradurlo.
  if (!entry) return '';

  // Controlla se il valore CSS e un colore.
  // Riconosce hex (#fff), rgb(), rgba(), hsl() e hsla().
  const isColor = /^(?:#|hsla?\(|rgba?\()/.test(styleValue.trim());
  const suffix = isColor ? '-color' : '';

  // Normalizza sempre in array per gestire uniformemente
  // sia le proprieta singole che quelle multiple.
  const props = Array.isArray(entry) ? entry : [entry];

  const result = props.map((prop) => `${prop}${suffix}`);

  // Restituisce una stringa se la proprieta e singola, un array se sono multiple.
  return result.length === 1 ? result[0] : result;
}

La regex /^(?:#|hsla?\(|rgba?\()/ merita attenzione. Il gruppo non-catturante (?:...) ancora tutte le alternative all'inizio della stringa grazie al ^ esterno. Senza il gruppo, l'alternation opererebbe sull'intera espressione e hsla?\( matcherebbe ovunque nella stringa, non solo all'inizio. Il pattern hsla?\( copre sia hsl( che hsla( con una sola alternativa, e la parentesi tonda escapata \( evita falsi positivi su stringhe che contengono casualmente le lettere "rgb" o "hsl".

Analizzare tutte le classi di un elemento

La funzione extractTWClasses riceve un elemento DOM e ne analizza l'attributo class. Filtra le classi che contengono un trattino (cioe quelle con un valore dinamico, come p-4 o bg-[#fff]), e per ciascuna separa il prefisso dal valore, invocando le due funzioni precedenti.

function extractTWClasses(element = null) {
  if (!element) return;
  if (!element.getAttribute('class')) return;

  // Ottiene tutte le classi dell'elemento come array di stringhe.
  const classes = element.className.split(' ');

  // Filtra solo le classi che contengono un trattino:
  // sono quelle con un valore dinamico (es. "p-4", "bg-[#fff]").
  // Le classi senza trattino (es. "flex", "hidden") sono statiche
  // e non richiedono generazione dinamica.
  const customValueClasses = classes.filter((cl) => cl.includes('-'));

  // Per ogni classe, separa il prefisso dal valore al primo trattino,
  // poi usa le funzioni di estrazione per ottenere proprieta e valore CSS.
  return customValueClasses.map((c) => {
    let parts = c.split('-');
    let cssValue = extractTWCSSValue(parts[1]);
    let cssProp = extractTWCSSProperty(parts[0], cssValue);
    return {
      property: cssProp,
      value: cssValue,
      cssClass: c,
    };
  });
}

Questa funzione restituisce un array di oggetti. Ogni oggetto contiene tre campi: property, che e la proprieta CSS (stringa o array di stringhe); value, che e il valore CSS gia convertito; e cssClass, che e il nome originale della classe, necessario per costruire il selettore. Ad esempio, per un elemento con classe p-4 bg-[#ff0000], la funzione restituirebbe:

[
  { property: 'padding',          value: '4px',     cssClass: 'p-4' },
  { property: 'background-color', value: '#ff0000', cssClass: 'bg-[#ff0000]' }
]

Fare l'escape dei selettori CSS

Le classi Tailwind con valori arbitrari contengono caratteri speciali come [, ], # e % che in CSS hanno significati sintattici. Prima di usarle come selettori in una regola CSS, vanno sottoposte a escape. Invece di gestire manualmente ogni carattere, deleghiamo il lavoro all'API nativa del browser CSS.escape(), che gestisce correttamente ogni caso limite.

// Esegue l'escape di tutti i caratteri speciali in un nome di classe
// per poterlo usare come selettore CSS valido.
// Esempio: "bg-[#ff0000]" diventa "bg-\[\#ff0000\]"
function escapeCSSClass(value = '') {
  if (!value) return '';
  return CSS.escape(value);
}

CSS.escape() e supportato da tutti i browser moderni. Gestisce automaticamente parentesi quadre, cancelletti, percentuali, punti, due punti, e qualsiasi altro carattere che potrebbe rompere un selettore CSS. Gestisce anche i casi limite come classi che iniziano con un numero o con un trattino seguito da un numero.

Applicare le regole al documento

La funzione applyTWClasses e il punto di ingresso principale. Riceve un elemento DOM, ne estrae le classi Tailwind, le converte in regole CSS e le inietta in un unico elemento <style> condiviso. Usa l'API CSSStyleSheet.insertRule() per aggiungere regole una alla volta, senza invalidare quelle gia presenti. Un Set tiene traccia delle regole gia inserite per evitare duplicati.

function applyTWClasses(element = null) {
  if (!element) return;

  const cssClasses = extractTWClasses(element);
  if (cssClasses.length === 0) return;

  // Riusa un singolo elemento <style> per tutte le chiamate,
  // invece di crearne uno nuovo ogni volta.
  let styleElement = document.getElementById('tw-dynamic-styles');
  if (!styleElement) {
    styleElement = document.createElement('style');
    styleElement.id = 'tw-dynamic-styles';
    document.head.appendChild(styleElement);
  }

  // Accede al foglio di stile tramite l'API CSSOM.
  const sheet = styleElement.sheet;

  // Raccoglie le regole gia esistenti in un Set
  // per evitare di inserire duplicati.
  const existingRules = new Set(
    Array.from(sheet.cssRules, (rule) => rule.cssText),
  );

  for (const cls of cssClasses) {
    // Normalizza la proprieta in array per gestire uniformemente
    // sia le proprieta singole che quelle multiple.
    const properties = Array.isArray(cls.property)
      ? cls.property
      : [cls.property];

    // Costruisce le dichiarazioni CSS.
    // Per classi multi-proprieta come "px-4", produce:
    // "padding-left: 4px; padding-right: 4px"
    const declarations = properties
      .map((prop) => `${prop}: ${cls.value}`)
      .join('; ');

    // Costruisce la regola completa con il selettore escapato.
    const rule = `.${escapeCSSClass(cls.cssClass)} { ${declarations} }`;

    // Inserisce la regola solo se non esiste gia.
    if (!existingRules.has(rule)) {
      sheet.insertRule(rule, sheet.cssRules.length);
      existingRules.add(rule);
    }
  }
}

Due scelte architetturali importanti in questa funzione. La prima e il riuso di un singolo <style> con un id fisso: questo evita l'accumulo di decine di tag <style> nel documento se la funzione viene chiamata ripetutamente. La seconda e l'uso di insertRule() al posto di innerHTML: innerHTML forza il browser a ri-parsare l'intero contenuto dell'elemento ogni volta, mentre insertRule() aggiunge una singola regola al foglio di stile senza invalidare le precedenti, risultando significativamente piu performante.

Avviare il tutto al caricamento della pagina

L'ultimo passo é collegare il sistema al DOM. Ascoltiamo l'evento DOMContentLoaded per assicurarci che tutti gli elementi siano disponibili prima di analizzarli. In questo esempio applichiamo il sistema al primo <div> trovato nel documento, ma la funzione puo essere invocata su qualsiasi elemento.

document.addEventListener(
  'DOMContentLoaded',
  () => {
    const element = document.querySelector('div');
    applyTWClasses(element);
  },
  false,
);

Il terzo parametro false indica che l'evento viene ascoltato nella fase di bubbling (il comportamento predefinito). Per applicare il sistema a tutti gli elementi della pagina, si potrebbe iterare su document.querySelectorAll('*') e invocare applyTWClasses su ciascuno.

Il codice completo

Ecco il codice finale, racchiuso in una IIFE per evitare di inquinare lo scope globale. Tutte le funzioni e la mappa restano private all'interno della closure.

'use strict';

(function () {
  const CLASS_TO_CSS_PROPERTY_MAP = {
    m: 'margin',
    mx: ['margin-left', 'margin-right'],
    my: ['margin-top', 'margin-bottom'],
    mt: 'margin-top',
    mr: 'margin-right',
    mb: 'margin-bottom',
    ml: 'margin-left',
    ms: 'margin-inline-start',
    me: 'margin-inline-end',
    p: 'padding',
    px: ['padding-left', 'padding-right'],
    py: ['padding-top', 'padding-bottom'],
    pt: 'padding-top',
    pr: 'padding-right',
    pb: 'padding-bottom',
    pl: 'padding-left',
    ps: 'padding-inline-start',
    pe: 'padding-inline-end',

    w: 'width',
    h: 'height',
    size: ['width', 'height'],
    'min-w': 'min-width',
    'max-w': 'max-width',
    'min-h': 'min-height',
    'max-h': 'max-height',

    border: 'border-width',
    'border-t': 'border-top-width',
    'border-r': 'border-right-width',
    'border-b': 'border-bottom-width',
    'border-l': 'border-left-width',
    'border-x': ['border-left-width', 'border-right-width'],
    'border-y': ['border-top-width', 'border-bottom-width'],
    rounded: 'border-radius',
    'rounded-t': ['border-top-left-radius', 'border-top-right-radius'],
    'rounded-r': ['border-top-right-radius', 'border-bottom-right-radius'],
    'rounded-b': ['border-bottom-left-radius', 'border-bottom-right-radius'],
    'rounded-l': ['border-top-left-radius', 'border-bottom-left-radius'],
    'rounded-tl': 'border-top-left-radius',
    'rounded-tr': 'border-top-right-radius',
    'rounded-bl': 'border-bottom-left-radius',
    'rounded-br': 'border-bottom-right-radius',

    bg: 'background',

    text: 'font-size',
    font: 'font-weight',
    tracking: 'letter-spacing',
    leading: 'line-height',
    indent: 'text-indent',

    gap: 'gap',
    'gap-x': 'column-gap',
    'gap-y': 'row-gap',
    basis: 'flex-basis',
    grow: 'flex-grow',
    shrink: 'flex-shrink',
    order: 'order',
    columns: 'columns',

    top: 'top',
    right: 'right',
    bottom: 'bottom',
    left: 'left',
    inset: 'inset',
    'inset-x': ['left', 'right'],
    'inset-y': ['top', 'bottom'],
    z: 'z-index',

    opacity: 'opacity',
    blur: 'filter',
    shadow: 'box-shadow',

    duration: 'transition-duration',
    delay: 'transition-delay',
    ease: 'transition-timing-function',

    scale: ['scale-x', 'scale-y'],
    'scale-x': 'scale-x',
    'scale-y': 'scale-y',
    rotate: 'rotate',
    'translate-x': 'translate-x',
    'translate-y': 'translate-y',

    accent: 'accent-color',
    outline: 'outline-width',
    'outline-offset': 'outline-offset',
    ring: 'box-shadow',
    'ring-offset': 'box-shadow',
    caret: 'caret-color',
    stroke: 'stroke-width',
    fill: 'fill',
  };

  function extractTWCSSValue(value = '') {
    if (!value) return '';
    const val = value.trim();

    if (val.startsWith('[') && val.endsWith(']')) {
      return val.slice(1, -1).replace(/_/g, ' ');
    }

    if (/^\d+(\.\d+)?$/.test(val)) {
      return `${val}px`;
    }

    if (
      /^\d+(\.\d+)?(rem|em|%|vh|vw|dvh|svh|ch|ex|px|pt|cm|mm|in)$/.test(val)
    ) {
      return val;
    }

    return val;
  }

  function extractTWCSSProperty(value = '', styleValue = '') {
    if (!value) return '';
    const val = value.trim();
    const entry = CLASS_TO_CSS_PROPERTY_MAP[val];

    if (!entry) return '';

    const isColor = /^(?:#|hsla?\(|rgba?\()/.test(styleValue.trim());
    const suffix = isColor ? '-color' : '';
    const props = Array.isArray(entry) ? entry : [entry];
    const result = props.map((prop) => `${prop}${suffix}`);

    return result.length === 1 ? result[0] : result;
  }

  function extractTWClasses(element = null) {
    if (!element) return;
    if (!element.getAttribute('class')) return;

    const classes = element.className.split(' ');
    const customValueClasses = classes.filter((cl) => cl.includes('-'));

    return customValueClasses.map((c) => {
      let parts = c.split('-');
      let cssValue = extractTWCSSValue(parts[1]);
      let cssProp = extractTWCSSProperty(parts[0], cssValue);
      return {
        property: cssProp,
        value: cssValue,
        cssClass: c,
      };
    });
  }

  function escapeCSSClass(value = '') {
    if (!value) return '';
    return CSS.escape(value);
  }

  function applyTWClasses(element = null) {
    if (!element) return;
    const cssClasses = extractTWClasses(element);
    if (cssClasses.length === 0) return;

    let styleElement = document.getElementById('tw-dynamic-styles');
    if (!styleElement) {
      styleElement = document.createElement('style');
      styleElement.id = 'tw-dynamic-styles';
      document.head.appendChild(styleElement);
    }

    const sheet = styleElement.sheet;
    const existingRules = new Set(
      Array.from(sheet.cssRules, (rule) => rule.cssText),
    );

    for (const cls of cssClasses) {
      const properties = Array.isArray(cls.property)
        ? cls.property
        : [cls.property];

      const declarations = properties
        .map((prop) => `${prop}: ${cls.value}`)
        .join('; ');

      const rule = `.${escapeCSSClass(cls.cssClass)} { ${declarations} }`;

      if (!existingRules.has(rule)) {
        sheet.insertRule(rule, sheet.cssRules.length);
        existingRules.add(rule);
      }
    }
  }

  document.addEventListener(
    'DOMContentLoaded',
    () => {
      const element = document.querySelector('div');
      applyTWClasses(element);
    },
    false,
  );
})();

Questo sistema copre una porzione significativa delle classi dinamiche di Tailwind CSS. Per estenderlo ulteriormente, i prossimi passi naturali sarebbero: gestire i modificatori responsive come md: e lg: tramite @media queries; supportare gli stati come hover: e focus: tramite pseudo-classi; e gestire le classi con prefissi negativi come -mt-4 per i valori negativi.

Demo

JavaScript Tailwind CSS classes