Attributs data

Utilisation des attributs data pour un style déclaratif et l'identification des composants.

Les attributs data offrent un moyen puissant d'exposer l'état et la structure des composants aux consommateurs, permettant un stylage flexible sans explosion de props. Les bibliothèques de composants modernes utilisent deux principaux modèles : data-state pour les états visuels et data-slot pour l'identification des composants.

Styliser l'état avec data-state

Un des anti-patterns les plus courants dans le stylage des composants est d'exposer des props className distinctes pour différents états.

Dans des composants moins modernes, vous verrez souvent des API comme ceci :

<Dialog
  openClassName="bg-black"
  closedClassName="bg-white"
  classes={{
    open: "opacity-100",
    closed: "opacity-0"
  }}
/>

Cette approche présente plusieurs problèmes :

  • Elle couple l'état interne du composant à son API de stylage
  • Elle crée une explosion de props à mesure que les composants deviennent plus complexes
  • Elle rend le composant plus difficile à utiliser et à maintenir
  • Elle empêche le stylage basé sur des combinaisons d'états

La solution : les attributs data-state

À la place, utilisez des attributs data-* pour exposer l'état du composant de manière déclarative. Cela permet aux consommateurs de styler les composants en fonction de l'état en utilisant des sélecteurs CSS standard :

component.tsx
const Dialog = ({ className, ...props }: DialogProps) => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div
      data-state={isOpen ? 'open' : 'closed'}
      className={cn('transition-all', className)}
      {...props}
    />
  );
};

Désormais, les consommateurs peuvent styliser le composant depuis l'extérieur en fonction de l'état :

app.tsx
<Dialog className="data-[state=open]:opacity-100 data-[state=closed]:opacity-0" />

Avantages de cette approche

  1. Single className prop - Pas besoin de multiples props className spécifiques à l'état
  2. Composable - Combinez plusieurs attributs data pour des états complexes
  3. Standard CSS - Fonctionne avec n'importe quelle solution CSS-in-JS ou avec du CSS pur
  4. Sécurisé par le typage - TypeScript peut inférer les valeurs des attributs data
  5. Inspectable - Les états sont visibles dans DevTools en tant qu'attributs HTML

Modèles d'état courants

Utilisez des attributs data pour toutes sortes d'états de composants :

// Open/closed state
<Accordion data-state={isOpen ? 'open' : 'closed'} />

// Selected state
<Tab data-state={isSelected ? 'active' : 'inactive'} />

// Disabled state (in addition to disabled attribute)
<Button data-disabled={isDisabled} disabled={isDisabled} />

// Loading state
<Button data-loading={isLoading} />

// Orientation
<Slider data-orientation="horizontal" />

// Side/position
<Tooltip data-side="top" />

Styliser avec Tailwind

Tailwind prend en charge les variantes arbitraires, rendant le stylage via attributs data élégant :

<Dialog
  className={cn(
    // Base styles
    'rounded-lg border p-4',
    // State-based styles
    'data-[state=open]:animate-in data-[state=open]:fade-in',
    'data-[state=closed]:animate-out data-[state=closed]:fade-out',
    // Multiple attributes
    'data-[state=open][data-side=top]:slide-in-from-top-2'
  )}
/>

Pour les états couramment utilisés, vous pouvez étendre la configuration de Tailwind :

tailwind.config.js
module.exports = {
  theme: {
    extend: {
      data: {
        open: 'state="open"',
        closed: 'state="closed"',
        active: 'state="active"',
      }
    }
  }
}

Vous pouvez alors utiliser la forme abrégée :

<Dialog className="data-open:opacity-100 data-closed:opacity-0" />

Intégration avec Radix UI

Ce modèle est largement utilisé par Radix UI, qui applique automatiquement des attributs data à ses primitives :

import * as Dialog from '@radix-ui/react-dialog';

<Dialog.Root>
  <Dialog.Trigger />
  <Dialog.Portal>
    {/* Radix automatically adds data-state="open" | "closed" */}
    <Dialog.Overlay className="data-[state=open]:animate-in data-[state=closed]:animate-out" />
    <Dialog.Content className="data-[state=open]:fade-in data-[state=closed]:fade-out" />
  </Dialog.Portal>
</Dialog.Root>

Autres attributs data fournis par Radix :

  • data-state - open/closed, active/inactive, on/off
  • data-side - top/right/bottom/left (pour les éléments positionnés)
  • data-align - start/center/end (pour les éléments positionnés)
  • data-orientation - horizontal/vertical
  • data-disabled - présent lorsque désactivé
  • data-placeholder - présent lors de l'affichage d'un placeholder

Identification des composants avec data-slot

Tandis que data-state suit les états visuels, data-slot identifie les types de composants au sein d'une composition. Ce modèle, popularisé par shadcn/ui, permet aux composants parents de cibler et styliser des composants enfants spécifiques sans s'appuyer sur des noms de classes fragiles ou des sélecteurs d'éléments.

Le problème du ciblage des enfants

Les approches traditionnelles pour styliser les composants enfants ont des limitations significatives :

// Relies on element types - breaks if implementation changes
<form className="[&_input]:rounded-lg [&_button]:mt-4" />

// Relies on class names - breaks if classes change
<form className="[&_.text-input]:rounded-lg" />

// Requires passing classes through props - verbose
<form>
  <input className={inputClasses} />
  <button className={buttonClasses} />
</form>

La solution : les attributs data-slot

Utilisez data-slot pour donner aux composants des identifiants stables pouvant être ciblés par les parents :

field-set.tsx
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
  return (
    <fieldset
      data-slot="field-set"
      className={cn(
        "flex flex-col gap-6",
        // Target specific child slots
        "has-[>[data-slot=checkbox-group]]:gap-3",
        "has-[>[data-slot=radio-group]]:gap-3",
        className
      )}
      {...props}
    />
  );
}
checkbox-group.tsx
function CheckboxGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="checkbox-group"
      className={cn("flex flex-col gap-2", className)}
      {...props}
    />
  );
}

Avantages de data-slot

  1. Identifiants stables - Ne casse pas lorsque les détails d'implémentation changent
  2. Ciblage sémantique - Ciblez en fonction du rôle du composant, pas de sa structure
  3. Encapsulation - Les classes internes restent privées
  4. Composable - Fonctionne avec n'importe quel niveau d'imbrication et de composition
  5. Sécurisé par le typage - Peut être validé et documenté

Utiliser has-[] pour un stylage conscient du parent

Le sélecteur has-[] de Tailwind combiné à data-slot crée un stylage puissant qui tient compte des parents :

form.tsx
function Form({ className, ...props }: React.ComponentProps<"form">) {
  return (
    <form
      data-slot="form"
      className={cn(
        "space-y-4",
        // Adjust spacing when specific slots are present
        "has-[>[data-slot=form-section]]:space-y-6",
        "has-[>[data-slot=inline-fields]]:space-y-2",
        // Style based on slot states
        "has-[[data-slot=submit-button][data-loading=true]]:opacity-50",
        className
      )}
      {...props}
    />
  );
}

Utiliser [&_] pour le ciblage des descendants

Pour des imbrications plus profondes, utilisez le modèle [&_selector] pour cibler n'importe quel descendant :

card.tsx
function Card({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card"
      className={cn(
        "rounded-lg border p-4",
        // Target any descendant with data-slot
        "[&_[data-slot=card-header]]:mb-4",
        "[&_[data-slot=card-title]]:text-lg [&_[data-slot=card-title]]:font-semibold",
        "[&_[data-slot=card-description]]:text-sm [&_[data-slot=card-description]]:text-muted-foreground",
        "[&_[data-slot=card-footer]]:mt-4 [&_[data-slot=card-footer]]:border-t [&_[data-slot=card-footer]]:pt-4",
        className
      )}
      {...props}
    />
  );
}

CSS global avec data-slot

Les data slots fonctionnent parfaitement avec le CSS global pour une cohérence à l'échelle du thème :

globals.css
/* Style all buttons within forms */
[data-slot="form"] [data-slot="button"] {
  @apply w-full sm:w-auto;
}

/* Style submit buttons specifically */
[data-slot="form"] [data-slot="submit-button"] {
  @apply bg-primary text-primary-foreground;
}

/* Adjust inputs within inline layouts */
[data-slot="inline-fields"] [data-slot="input"] {
  @apply flex-1;
}

/* Style based on state combinations */
[data-slot="dialog"][data-state="open"] [data-slot="dialog-content"] {
  @apply animate-in fade-in;
}

Conventions de nommage

Suivez ces conventions pour des noms data-slot cohérents :

  1. Use kebab-case - data-slot="form-field" not data-slot="formField"
  2. Be specific - data-slot="submit-button" not data-slot="button"
  3. Match component purpose - Le nom reflète ce que fait le composant, pas son apparence
  4. Avoid implementation details - data-slot="user-avatar" not data-slot="rounded-image"
// Good examples
data-slot="search-input"
data-slot="navigation-menu"
data-slot="error-message"
data-slot="submit-button"
data-slot="card-header"

// Avoid
data-slot="input"           // Too generic
data-slot="blueButton"      // Includes styling
data-slot="div-wrapper"     // Implementation detail
data-slot="mainContent"     // Use camelCase

Quand utiliser les attributs data vs props

Comprendre quand utiliser chaque modèle est la clé d'une API propre :

Cas d'utilisation de data-state

  • Visual states - open/closed, active/inactive, loading, etc.
  • Layout states - orientation, side, alignment
  • Interaction states - hover, focus, disabled (quand vous devez styler des enfants)

Cas d'utilisation de data-slot

  • Component identification - Identifiants stables pour le ciblage
  • Composition patterns - Relations parent-enfant
  • Global styling - Stylage des composants à l'échelle du thème
  • Variant-independent targeting - Cibler n'importe quelle variante d'un composant

Cas d'utilisation des props

  • Variants - Différents designs visuels (primary, secondary, destructive)
  • Sizes - sm, md, lg
  • Behavioral configuration - controlled/uncontrolled, valeurs par défaut
  • Event handlers - onClick, onChange, etc.

Approche combinée

Un composant bien conçu utilise ces trois modèles de manière appropriée :

button.tsx
type ButtonProps = {
  variant?: 'primary' | 'secondary' | 'destructive';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
  disabled?: boolean;
  onClick?: () => void;
  className?: string;
};

const Button = ({
  variant = 'primary',
  size = 'md',
  loading,
  disabled,
  className,
  ...props
}: ButtonProps) => {
  return (
    <button
      // Slot for targeting
      data-slot="button"
      // State for conditional styling
      data-loading={loading}
      data-disabled={disabled}
      className={cn(
        // Variant styles via props
        buttonVariants({ variant, size }),
        // Additional state styling allowed via className
        className
      )}
      disabled={disabled}
      {...props}
    />
  );
};

Le bouton peut désormais être utilisé et stylisé de plusieurs manières :

// Basic usage with variants
<Button variant="primary" size="lg">Submit</Button>

// Parent targeting via data-slot
<form className="[&_[data-slot=button]]:w-full">
  <Button>Submit</Button>
</form>

// State-based styling via data-state
<Button
  loading={isLoading}
  className="data-[loading=true]:opacity-50"
>
  Submit
</Button>

// Global CSS can target any button
// [data-slot="button"][data-loading="true"] { ... }

Les attributs data fournissent une base robuste pour le stylage des bibliothèques de composants modernes. En utilisant data-state pour les états visuels et data-slot pour l'identification des composants, vous créez une API flexible et maintenable qui s'étend des composants simples aux systèmes de design complexes.