ma-lib-ui

thomias-ui

Une bibliothèque de snippets CSS d'abord, pour styler les éléments HTML natifs (<button>, <input>, <select>, <dialog>...) plutôt que de les remplacer par des balises custom.

Chaque composant de cette doc part d'une balise HTML native, lui ajoute une ou deux classes (.ui-*), et n'ajoute du JavaScript que quand le navigateur ne sait vraiment pas s'en passer. Pas de Shadow DOM, pas de composant custom : ce que tu copies est ce qui s'exécute.

Philosophie

  • Éléments natifs d'abord<button>, <input>, <select>, <dialog>, <details>... plutôt que des balises custom.
  • CSS avant tout — l'essentiel du rendu et du comportement (accordéon, onglets, bascule) tient dans les classes .ui-*, sans JavaScript.
  • Zéro dépendance — aucun import, aucun build ; les snippets fonctionnent tels quels dans n'importe quelle page.
  • Personnalisable — un seul fichier de variables (tokens.css) pilote toute l'apparence.
CSS-first CSS + JS minimal JS requis

La doc signale les composants qui tiennent entièrement en CSS, ceux qui gardent juste un peu de JS pour l'état ou les événements, et ceux qui ont un vrai comportement applicatif impossible à éviter (toast, drag&drop...).

Chaque exemple propose jusqu'à 3 zones copiables indépendamment : HTML, CSS et JavaScript (cette dernière n'apparaît que si le composant en a réellement besoin). Passe en mode Source brute dans la barre supérieure pour masquer les démos rendues et ne garder que les snippets.

Installation

Copie assets/css/tokens.css et assets/css/components.css dans ton projet, puis dans ta page :

<link rel="stylesheet" href="./assets/css/tokens.css" />
<link rel="stylesheet" href="./assets/css/components.css" />

<button class="ui-btn ui-btn--primary">Cliquer ici</button>

C'est tout : pas de npm install, pas d'étape de compilation, pas de <script> obligatoire. Le JavaScript ne s'ajoute qu'au cas par cas, uniquement pour les composants marqués CSS + JS minimal ou JS requis — et c'est alors la zone JS de la doc que tu colles, sans import ni dépendance.

Pour naviguer dans cette page de documentation (recherche, filtres, copie par zone), ouvre-la via HTTP et non via file://, car assets/js/docs.js est un module ES. Le plus simple :

cd /Users/thomas/Desktop/thomias-ui
python3 -m http.server 4173

Cette contrainte ne concerne que la doc elle-même : dans ton propre projet, les snippets copiés fonctionnent aussi bien en file:// qu'en HTTP.

Exemples rapides

Quelques compositions prêtes à reprendre telles quelles, pour voir comment les composants se combinent dans une interface réelle.

Formulaire de connexion

Un cas simple avec carte, champs et action principale.

Exemple rendu

Connexion

Code à copier

<article class="ui-card" style="max-width:420px;">
  <header class="ui-card__header">Connexion</header>
  <div class="ui-card__body">
    <div class="ui-space ui-space--vertical" style="width:100%;">
      <label class="ui-label" for="email">Adresse e-mail</label>
      <input class="ui-input" type="email" id="email" placeholder="Adresse e-mail" />

      <label class="ui-label" for="password">Mot de passe</label>
      <input class="ui-input" type="password" id="password" placeholder="Mot de passe" />

      <label class="ui-checkbox"><input type="checkbox" /> Se souvenir de moi</label>
      <button class="ui-btn ui-btn--primary">Se connecter</button>
    </div>
  </div>
</article>

Barre de filtres

Un en-tête de listing avec recherche, statut et tri.

Exemple rendu

Code à copier

<article class="ui-card">
  <div class="ui-card__body">
    <div class="ui-row" style="--ui-row-gutter:12px; align-items:end;">
      <div class="ui-col" style="--ui-col-span:10">
        <input class="ui-input" type="search" placeholder="Rechercher une commande" aria-label="Rechercher une commande" />
      </div>
      <div class="ui-col" style="--ui-col-span:7">
        <select class="ui-select" aria-label="Statut">
          <option value="" disabled selected>Statut</option>
          <option value="new">Nouvelle</option>
          <option value="paid">Payée</option>
          <option value="sent">Expédiée</option>
        </select>
      </div>
      <div class="ui-col" style="--ui-col-span:7">
        <div class="ui-segmented">
          <input type="radio" name="filter-sort" id="sort-recent" class="ui-segmented__input" checked />
          <label for="sort-recent" class="ui-segmented__item">Récentes</label>
          <input type="radio" name="filter-sort" id="sort-amount" class="ui-segmented__input" />
          <label for="sort-amount" class="ui-segmented__item">Montant</label>
        </div>
      </div>
    </div>
  </div>
</article>

État de confirmation

Une sortie claire après une action importante, avec résumé et prochaine étape.

Exemple rendu

Commande validée

Le paiement a bien été reçu et la préparation a commencé.

Code à copier

<article class="ui-card">
  <div class="ui-card__body">
    <div class="ui-result ui-result--success">
      <svg class="ui-icon" ...>...</svg>
      <h3>Commande validée</h3>
      <p>Le paiement a bien été reçu et la préparation a commencé.</p>
      <div class="ui-result__actions">
        <button class="ui-btn ui-btn--primary">Voir le détail</button>
        <button class="ui-btn ui-btn--primary ui-btn--plain">Retour au catalogue</button>
      </div>
    </div>
  </div>
</article>

Explorer les composants

Une galerie générée depuis la documentation pour comparer rapidement le rendu de chaque composant et retrouver plus vite le bon point d'entrée.

Chargement des aperçus…

Thème & personnalisation

Le kit inclut un mode clair, sombre, et automatique (basé sur les préférences système). Teste le sélecteur en haut de la page.

Le thème repose sur l'attribut data-theme posé sur <html> et sur les variables définies dans assets/css/tokens.css. Un petit script (assets/js/theme.js) gère la persistance (localStorage) et la résolution du mode « auto ».

Variables principales

VariableRôle
--ui-color-primaryCouleur d'accent principale (boutons, liens, focus)
--ui-color-successÉtats de succès
--ui-color-warningÉtats d'avertissement
--ui-color-dangerÉtats d'erreur / actions destructrices
--ui-color-bg / --ui-color-bg-elevatedFonds de page et de surface (cartes, champs...)
--ui-color-text / --ui-color-text-secondaryTexte principal et secondaire
--ui-color-borderBordures par défaut
--ui-radius-sm / md / lgRayons d'arrondi
--ui-spacing-xsxlÉchelle d'espacement

Pour personnaliser, redéfinis ces variables sur :root (et sur :root[data-theme='dark'] pour le mode sombre) dans le CSS de ton propre projet — inutile de toucher aux fichiers du kit.

:root {
  --ui-color-primary: #7c3aed;
  --ui-radius-md: 10px;
}

Row & Col

CSS-first

Grille flexible à 24 colonnes basée sur display: grid natif : gouttière et largeurs passent par des variables CSS inline, sans aucun calcul JavaScript.

span 8
span 8
span 8
span 6, offset 6
span 6
<div class="ui-row" style="--ui-row-gutter:16px">
  <div class="ui-col" style="--ui-col-span:8">span 8</div>
  <div class="ui-col" style="--ui-col-span:8">span 8</div>
  <div class="ui-col" style="--ui-col-span:8">span 8</div>
</div>

<div class="ui-row" style="--ui-row-gutter:16px">
  <div class="ui-col" style="--ui-col-span:6;--ui-col-offset:6">span 6, offset 6</div>
  <div class="ui-col" style="--ui-col-span:6">span 6</div>
</div>

<style>
.ui-row {
  display: grid;
  grid-template-columns: repeat(24, 1fr);
  gap: var(--ui-row-gutter, 0px);
}
.ui-col {
  grid-column: span var(--ui-col-span, 24);
  margin-left: calc((100% / 24) * var(--ui-col-offset, 0));
}
</style>
Classe / attributDescription
.ui-rowconteneur en grille CSS à 24 colonnes
style="--ui-row-gutter:<px>"espacement entre les colonnes (défaut 0)
.ui-colcolonne, largeur pilotée par --ui-col-span
style="--ui-col-span:<1-24>"largeur en 24e (défaut 24)
style="--ui-col-offset:<n>"décalage à gauche en 24e (défaut 0)

Space

CSS-first

Espacement régulier entre des éléments avec display: flex et gap natif, sans avoir à gérer des marges au cas par cas.

Ligne 1 Ligne 2
<div class="ui-space">
  <button class="ui-btn ui-btn--primary">Un</button>
  <button class="ui-btn ui-btn--primary">Deux</button>
  <button class="ui-btn ui-btn--primary">Trois</button>
</div>

<div class="ui-space ui-space--vertical">
  <span>Ligne 1</span>
  <span>Ligne 2</span>
</div>

<style>
.ui-space {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ui-spacing-md);
}
.ui-space--vertical {
  flex-direction: column;
}
</style>
Classe / attributDescription
.ui-spaceconteneur flex avec gap: var(--ui-spacing-md)
.ui-space--verticalbascule en flex-direction: column
style="gap:...px"surcharge l'espacement par défaut, propriété CSS native

Affix

CSS-first

Fixe un bloc au défilement une fois un seuil atteint grâce à position: sticky, qui remplace complètement l'ancien calcul de scroll en JavaScript.

Je reste visible en défilant dans ce panneau

Fais défiler ce panneau vers le bas : le bloc ci-dessus reste collé en haut.

Contenu supplémentaire pour dépasser la hauteur visible et provoquer un défilement.

Encore un peu de texte pour garantir la barre de défilement.

<div style="height:200px;overflow:auto">
  <div class="ui-affix" style="top:0">
    <div>Barre d'outils</div>
  </div>
  <p>Contenu qui défile...</p>
</div>

<style>
.ui-affix {
  position: sticky;
}
</style>
Classe / attributDescription
.ui-affixposition: sticky, se colle au bord de son conteneur défilant
style="top:<px>"distance depuis le haut de la zone de défilement avant que ça colle (défaut 0)

Pagination

CSS-first

Navigation par pages en boutons natifs ; la page active et l'ellipse sont posées directement dans le HTML de la démo (une vraie application calculerait ces pages côté serveur ou avec quelques lignes de JS).

<nav class="ui-pagination" aria-label="Pagination">
  <button class="ui-pagination__item" aria-label="Page précédente">...</button>
  <button class="ui-pagination__item">1</button>
  <span class="ui-pagination__ellipsis">…</span>
  <button class="ui-pagination__item is-active" aria-current="page">4</button>
  <button class="ui-pagination__item">5</button>
  <span class="ui-pagination__ellipsis">…</span>
  <button class="ui-pagination__item">23</button>
  <button class="ui-pagination__item" aria-label="Page suivante">...</button>
</nav>

<style>
.ui-pagination {
  display: flex;
  align-items: center;
  gap: var(--ui-spacing-xs);
}
.ui-pagination__item {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 32px;
  height: 32px;
  padding: 0 8px;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  font: inherit;
  font-size: var(--ui-font-size-sm);
  cursor: pointer;
  transition: background var(--ui-transition-fast), border-color var(--ui-transition-fast);
}
.ui-pagination__item:hover { border-color: var(--ui-color-border-hover); }
.ui-pagination__item:focus-visible { outline: none; box-shadow: var(--ui-focus-ring); }
.ui-pagination__item.is-active {
  background: var(--ui-color-primary);
  border-color: var(--ui-color-primary);
  color: var(--ui-color-text-on-primary);
}
.ui-pagination__ellipsis {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 32px;
  height: 32px;
  color: var(--ui-color-text-secondary);
}
</style>
Classe / attributDescription
.ui-paginationconteneur <nav> flex des items de page
.ui-pagination__itembouton de page, à poser sur <button>
.is-active + aria-current="page"page actuellement affichée
.ui-pagination__ellipsisséparateur pour les pages masquées

Steps

CSS-first

Étapes d'un processus avec statut visuel (fait / en cours / à venir) ; la numérotation est générée par des compteurs CSS, sans JavaScript.

  1. Compte
    Informations de connexion
  2. Profil
    Coordonnées
  3. Confirmation
    Validation finale
<ol class="ui-steps">
  <li class="ui-step is-done">
    <div class="ui-step__icon"></div>
    <div class="ui-step__title">Compte</div>
    <div class="ui-step__desc">Informations de connexion</div>
  </li>
  <li class="ui-step is-current">
    <div class="ui-step__icon"></div>
    <div class="ui-step__title">Profil</div>
    <div class="ui-step__desc">Coordonnées</div>
  </li>
  <li class="ui-step">
    <div class="ui-step__icon"></div>
    <div class="ui-step__title">Confirmation</div>
    <div class="ui-step__desc">Validation finale</div>
  </li>
</ol>

<style>
.ui-steps {
  display: flex;
  list-style: none;
  margin: 0;
  padding: 0;
  counter-reset: ui-step;
}
.ui-step {
  flex: 1;
  counter-increment: ui-step;
}
.ui-step__icon {
  display: flex;
  align-items: center;
  gap: var(--ui-spacing-sm);
  margin-bottom: var(--ui-spacing-sm);
}
.ui-step__icon::before {
  content: counter(ui-step);
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: 1px solid var(--ui-color-border);
  color: var(--ui-color-text-secondary);
  font-size: var(--ui-font-size-sm);
}
.ui-step__icon::after {
  content: '';
  flex: 1;
  height: 1px;
  background: var(--ui-color-border);
}
.ui-step:last-child .ui-step__icon::after { display: none; }
.ui-step__title {
  font-size: var(--ui-font-size-md);
  color: var(--ui-color-text);
  font-weight: 500;
}
.ui-step__desc {
  font-size: var(--ui-font-size-sm);
  color: var(--ui-color-text-secondary);
}
.ui-step.is-done .ui-step__icon::before {
  background: var(--ui-color-primary);
  border-color: var(--ui-color-primary);
  color: var(--ui-color-text-on-primary);
}
.ui-step.is-done .ui-step__icon::after { background: var(--ui-color-primary); }
.ui-step.is-current .ui-step__icon::before {
  border-color: var(--ui-color-primary);
  color: var(--ui-color-primary);
}
.ui-step.is-current .ui-step__title { color: var(--ui-color-primary); }
</style>
Classe / attributDescription
.ui-stepsliste <ol> flex, numérotée par compteur CSS
.ui-stepune étape
.is-doneétape terminée
.is-currentétape active
.ui-step__title / .ui-step__desctitre et description de l'étape

Segmented

CSS-first

Groupe de choix exclusif en forme de pilule, construit avec des radios natifs masqués (:checked + sélecteur de fratrie), alternative compacte aux boutons radio classiques.

<div class="ui-segmented">
  <input type="radio" name="demo-segmented" id="seg-list" class="ui-segmented__input" checked />
  <label for="seg-list" class="ui-segmented__item">Liste</label>
  <input type="radio" name="demo-segmented" id="seg-grid" class="ui-segmented__input" />
  <label for="seg-grid" class="ui-segmented__item">Grille</label>
  <input type="radio" name="demo-segmented" id="seg-table" class="ui-segmented__input" disabled />
  <label for="seg-table" class="ui-segmented__item">Tableau</label>
</div>

<style>
.ui-segmented {
  position: relative;
  display: inline-flex;
  gap: 2px;
  padding: 2px;
  background: var(--ui-color-bg-subtle);
  border-radius: var(--ui-radius-full);
}
.ui-segmented__input {
  position: absolute;
  width: 0;
  height: 0;
  opacity: 0;
}
.ui-segmented__item {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 30px;
  padding: 0 var(--ui-spacing-md);
  border-radius: var(--ui-radius-full);
  color: var(--ui-color-text-secondary);
  font-size: var(--ui-font-size-sm);
  cursor: pointer;
  transition: background var(--ui-transition-fast), color var(--ui-transition-fast);
}
.ui-segmented__input:checked + .ui-segmented__item {
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  box-shadow: var(--ui-shadow-sm);
}
.ui-segmented__input:focus-visible + .ui-segmented__item {
  outline: none;
  box-shadow: var(--ui-focus-ring);
}
.ui-segmented__input:disabled + .ui-segmented__item {
  color: var(--ui-color-disabled-text);
  cursor: not-allowed;
}
</style>
Classe / attributDescription
.ui-segmentedconteneur en pilule
.ui-segmented__inputradio natif masqué, même name pour tout le groupe
.ui-segmented__itemlabel visible associé via for
checked, disabledattributs natifs de <input type="radio">

Backtop

CSS + JS minimal

Bouton flottant pour remonter en haut de page. Le style et la position utilisent position: fixed ; seule l'apparition après un seuil de défilement demande quelques lignes de JS, car le CSS ne peut pas lire scrollY.

Fais défiler la page de plus de 200px : le bouton apparaît en bas à droite de l'écran.

<a class="ui-backtop" id="backtop" href="#top" aria-label="Remonter en haut">
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</a>

<style>
.ui-backtop {
  position: fixed;
  right: var(--ui-spacing-xl);
  bottom: var(--ui-spacing-xl);
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text-secondary);
  box-shadow: var(--ui-shadow-md);
  opacity: 0;
  visibility: hidden;
  transform: translateY(8px);
  transition: opacity var(--ui-transition), transform var(--ui-transition), visibility var(--ui-transition);
  z-index: 100;
}
.ui-backtop.is-visible {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}
.ui-backtop:hover { color: var(--ui-color-primary); box-shadow: var(--ui-shadow-lg); }
.ui-backtop:focus-visible { outline: none; box-shadow: var(--ui-focus-ring); }
</style>

<script>
  var backtop = document.getElementById('backtop')
  window.addEventListener('scroll', () => {
    backtop.classList.toggle('is-visible', window.scrollY > 200)
  })
</script>
Classe / attributDescription
.ui-backtopbouton fixé en bas à droite, masqué par défaut
.is-visibleclasse ajoutée en JS quand window.scrollY dépasse le seuil
href="#top"remonte en haut de page nativement, sans JS
aria-labellibellé accessible (le bouton ne contient qu'une icône)

Button

CSS-first

Un <button> natif, avec des classes .ui-btn pour les variantes de couleur, de taille et d'état — aucun JavaScript requis, même pour le spinner de chargement.

<button class="ui-btn ui-btn--primary">Primary</button>
<button class="ui-btn ui-btn--primary ui-btn--plain">Plain</button>
<button class="ui-btn ui-btn--primary ui-btn--round">Round</button>
<button class="ui-btn ui-btn--primary ui-btn--loading" aria-busy="true" disabled>Chargement</button>
<button class="ui-btn" disabled>Désactivé</button>

<style>
.ui-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  height: 36px;
  padding: 0 16px;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  font: inherit;
  font-size: var(--ui-font-size-md);
  cursor: pointer;
  transition: background var(--ui-transition-fast), border-color var(--ui-transition-fast);
}
.ui-btn:hover { border-color: var(--ui-color-border-hover); }
.ui-btn:focus-visible { outline: none; box-shadow: var(--ui-focus-ring); }
.ui-btn:disabled { background: var(--ui-color-disabled-bg); border-color: var(--ui-color-disabled-border); color: var(--ui-color-disabled-text); cursor: not-allowed; }

.ui-btn--primary { background: var(--ui-color-primary); border-color: var(--ui-color-primary); color: var(--ui-color-text-on-primary); }
.ui-btn--primary:hover:not(:disabled) { background: var(--ui-color-primary-hover); border-color: var(--ui-color-primary-hover); }
.ui-btn--success { background: var(--ui-color-success); border-color: var(--ui-color-success); color: #fff; }
.ui-btn--warning { background: var(--ui-color-warning); border-color: var(--ui-color-warning); color: #fff; }
.ui-btn--danger { background: var(--ui-color-danger); border-color: var(--ui-color-danger); color: #fff; }
.ui-btn--info { background: var(--ui-color-info); border-color: var(--ui-color-info); color: #fff; }

.ui-btn--plain { background: var(--ui-color-primary-soft); color: var(--ui-color-primary); }
.ui-btn--round { border-radius: var(--ui-radius-full); }
.ui-btn--circle { width: 36px; padding: 0; border-radius: 50%; }
.ui-btn--sm { height: 28px; padding: 0 12px; font-size: var(--ui-font-size-sm); }
.ui-btn--lg { height: 44px; padding: 0 20px; font-size: var(--ui-font-size-lg); }

.ui-btn--loading { position: relative; pointer-events: none; opacity: 0.85; }
.ui-btn--loading::before {
  content: '';
  width: 14px;
  height: 14px;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: ui-btn-spin 0.6s linear infinite;
}
@keyframes ui-btn-spin {
  to { transform: rotate(360deg); }
}
</style>
ClasseRôle
.ui-btnbase, à poser sur un <button>
.ui-btn--primary / --success / --warning / --danger / --infovariante de couleur
.ui-btn--plainfond adouci (teinte "soft")
.ui-btn--round / --circlecoins en pilule / bouton carré pour icône seule
.ui-btn--sm / --lgtaille
.ui-btn--loadingajoute un spinner CSS ; à combiner avec aria-busy="true" et disabled
disabled (attribut natif)état désactivé, géré par :disabled

Input

CSS-first

<input> natif stylé en .ui-input. L'effacement utilise type="search", qui fournit une croix native (Chrome/Edge) sans JavaScript.

<label class="ui-label" for="username">Nom d'utilisateur</label>
<input class="ui-input" id="username" placeholder="Nom d'utilisateur" />

<label class="ui-label" for="password">Mot de passe</label>
<input class="ui-input" type="password" id="password" placeholder="Mot de passe" />

<label class="ui-label" for="search">Recherche</label>
<input class="ui-input" type="search" id="search" placeholder="Recherche effaçable" />

<label class="ui-label" for="icon-field">Avec icône</label>
<div class="ui-input-group">
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
  <input class="ui-input" id="icon-field" placeholder="Avec icône" />
</div>

<style>
.ui-input {
  display: block;
  width: 100%;
  height: 36px;
  padding: 0 12px;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  font: inherit;
  font-size: var(--ui-font-size-md);
  transition: border-color var(--ui-transition-fast);
}
.ui-input::placeholder { color: var(--ui-color-text-placeholder); }
.ui-input:hover { border-color: var(--ui-color-border-hover); }
.ui-input:focus { outline: none; border-color: var(--ui-color-primary); box-shadow: var(--ui-focus-ring); }
.ui-input:disabled { background: var(--ui-color-disabled-bg); border-color: var(--ui-color-disabled-border); color: var(--ui-color-disabled-text); cursor: not-allowed; }
.ui-input[type='search']::-webkit-search-cancel-button { cursor: pointer; }

.ui-input-group {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 36px;
  padding: 0 12px;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text-secondary);
}
.ui-input-group:focus-within { border-color: var(--ui-color-primary); box-shadow: var(--ui-focus-ring); }
.ui-input-group .ui-input { flex: 1; border: none; height: auto; padding: 0; box-shadow: none !important; }
</style>
Classe / attributDescription
.ui-inputstyle de base, à poser sur <input>
.ui-labellabel associé via for/id — le placeholder seul n'est pas un substitut accessible
type="search"ajoute une croix d'effacement native, stylée via ::-webkit-search-cancel-button
.ui-input-groupconteneur pour associer une icône SVG à un champ
placeholder, value, disabled, readonlyattributs natifs de <input>, inchangés

Textarea

CSS-first

Zone de texte multi-lignes native ; le redimensionnement (resize) est géré par le navigateur, sans JavaScript.

<label class="ui-label" for="message">Votre message</label>
<textarea class="ui-textarea" id="message" placeholder="Votre message" rows="4"></textarea>

<style>
.ui-textarea {
  display: block;
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  font: inherit;
  font-size: var(--ui-font-size-md);
  line-height: var(--ui-line-height);
  resize: vertical;
  transition: border-color var(--ui-transition-fast);
}
.ui-textarea::placeholder { color: var(--ui-color-text-placeholder); }
.ui-textarea:focus { outline: none; border-color: var(--ui-color-primary); box-shadow: var(--ui-focus-ring); }
.ui-textarea:disabled { background: var(--ui-color-disabled-bg); border-color: var(--ui-color-disabled-border); color: var(--ui-color-disabled-text); cursor: not-allowed; resize: none; }
</style>
Classe / attributDescription
.ui-textareastyle de base
rowsattribut natif, nombre de lignes visibles (défaut : 3)
CSS resizevertical (défaut ici), none, horizontal, both — poignée native du navigateur
disabled, readonly, placeholder, valueattributs natifs de <textarea>, inchangés

Checkbox

CSS + JS minimal

<input type="checkbox"> personnalisé en CSS pur (appearance: none + ::before). Seul l'état indéterminé demande une ligne de JS : il n'existe pas d'attribut HTML pour ça, uniquement une propriété .indeterminate.

<label class="ui-checkbox"><input type="checkbox" checked /> Option A</label>
<label class="ui-checkbox"><input type="checkbox" /> Option B</label>
<label class="ui-checkbox"><input type="checkbox" id="checkbox-indeterminate" /> Indéterminé</label>
<label class="ui-checkbox"><input type="checkbox" disabled /> Désactivé</label>

<style>
.ui-checkbox { display: inline-flex; align-items: center; gap: 8px; font-size: var(--ui-font-size-md); color: var(--ui-color-text); cursor: pointer; }
.ui-checkbox input {
  appearance: none;
  width: 18px;
  height: 18px;
  margin: 0;
  border: 1px solid var(--ui-color-border);
  border-radius: 4px;
  background: var(--ui-color-bg-elevated);
  display: grid;
  place-content: center;
  cursor: pointer;
  transition: background var(--ui-transition-fast), border-color var(--ui-transition-fast);
}
.ui-checkbox input::before {
  content: '';
  width: 10px;
  height: 10px;
  transform: scale(0);
  transition: transform var(--ui-transition-fast);
  box-shadow: inset 1em 1em var(--ui-color-text-on-primary);
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
.ui-checkbox input:checked { background: var(--ui-color-primary); border-color: var(--ui-color-primary); }
.ui-checkbox input:checked::before { transform: scale(1); }
.ui-checkbox input:indeterminate { background: var(--ui-color-primary); border-color: var(--ui-color-primary); }
.ui-checkbox input:indeterminate::before { clip-path: none; width: 10px; height: 2px; transform: scale(1); }
.ui-checkbox input:focus-visible { outline: none; box-shadow: var(--ui-focus-ring); }
.ui-checkbox input:disabled { background: var(--ui-color-disabled-bg); border-color: var(--ui-color-disabled-border); cursor: not-allowed; }
.ui-checkbox:has(input:disabled) { color: var(--ui-color-disabled-text); cursor: not-allowed; }
</style>

<script>
  document.getElementById('checkbox-indeterminate').indeterminate = true
</script>
Classe / propriétéDescription
.ui-checkboxlabel conteneur
checked, disabledattributs natifs de <input type="checkbox">
.indeterminatepropriété JS uniquement (pas d'attribut HTML) : checkbox.indeterminate = true

Radio

CSS-first

Des <input type="radio"> qui partagent un même name : la sélection exclusive est gérée nativement, sans JavaScript.

<label class="ui-radio"><input type="radio" name="group" value="a" checked /> Option A</label>
<label class="ui-radio"><input type="radio" name="group" value="b" /> Option B</label>
<label class="ui-radio"><input type="radio" name="group" value="c" disabled /> Option C</label>

<style>
.ui-radio { display: inline-flex; align-items: center; gap: 8px; font-size: var(--ui-font-size-md); color: var(--ui-color-text); cursor: pointer; }
.ui-radio input {
  appearance: none;
  width: 18px;
  height: 18px;
  margin: 0;
  border: 1px solid var(--ui-color-border);
  border-radius: 50%;
  background: var(--ui-color-bg-elevated);
  display: grid;
  place-content: center;
  cursor: pointer;
  transition: border-color var(--ui-transition-fast);
}
.ui-radio input::before {
  content: '';
  width: 8px;
  height: 8px;
  border-radius: 50%;
  transform: scale(0);
  transition: transform var(--ui-transition-fast);
  background: var(--ui-color-primary);
}
.ui-radio input:checked { border-color: var(--ui-color-primary); }
.ui-radio input:checked::before { transform: scale(1); }
.ui-radio input:focus-visible { outline: none; box-shadow: var(--ui-focus-ring); }
.ui-radio input:disabled { background: var(--ui-color-disabled-bg); border-color: var(--ui-color-disabled-border); cursor: not-allowed; }
.ui-radio:has(input:disabled) { color: var(--ui-color-disabled-text); cursor: not-allowed; }
</style>
Classe / attributDescription
.ui-radiolabel conteneur
nameattribut natif, regroupe les radios pour une sélection exclusive
checked, disabledattributs natifs de <input type="radio">

Switch

CSS-first

Toujours un <input type="checkbox">, juste redessiné en bascule via appearance: none — le comportement coché/décoché reste 100% natif.

<label class="ui-switch-row">
  <input type="checkbox" class="ui-switch" checked /> Notifications
</label>

<style>
.ui-switch-row { display: inline-flex; align-items: center; gap: 8px; font-size: var(--ui-font-size-md); color: var(--ui-color-text); cursor: pointer; }
.ui-switch {
  appearance: none;
  width: 40px;
  height: 22px;
  margin: 0;
  border-radius: var(--ui-radius-full);
  background: var(--ui-color-disabled-bg);
  border: 1px solid var(--ui-color-border);
  position: relative;
  cursor: pointer;
  transition: background var(--ui-transition-fast), border-color var(--ui-transition-fast);
}
.ui-switch::before {
  content: '';
  position: absolute;
  top: 1px;
  left: 1px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--ui-color-bg-elevated);
  box-shadow: var(--ui-shadow-sm);
  transition: transform var(--ui-transition-fast);
}
.ui-switch:checked { background: var(--ui-color-primary); border-color: var(--ui-color-primary); }
.ui-switch:checked::before { transform: translateX(18px); }
.ui-switch:focus-visible { outline: none; box-shadow: var(--ui-focus-ring); }
.ui-switch:disabled { cursor: not-allowed; opacity: 0.7; }
.ui-switch-row:has(.ui-switch:disabled) { color: var(--ui-color-disabled-text); cursor: not-allowed; }
</style>
Classe / attributDescription
.ui-switchà poser sur <input type="checkbox"> pour un rendu bascule
.ui-switch-rowlabel conteneur — un switch doit toujours avoir un intitulé associé, le style seul ne suffit pas
checked, disabledattributs natifs, inchangés

Select

CSS-first

Un <select> natif, avec juste une bordure, un rayon et une flèche personnalisés en CSS.

<label class="ui-label" for="fruit">Fruit</label>
<select class="ui-select" id="fruit">
  <option value="" disabled selected>Choisir un fruit</option>
  <option value="apple">Pomme</option>
  <option value="banana">Banane</option>
</select>

<style>
.ui-select {
  appearance: none;
  display: block;
  width: 100%;
  height: 36px;
  padding: 0 32px 0 12px;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated)
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%235b6470' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E")
    no-repeat right 8px center / 16px;
  color: var(--ui-color-text);
  font: inherit;
  font-size: var(--ui-font-size-md);
  cursor: pointer;
  transition: border-color var(--ui-transition-fast);
}
.ui-select:hover { border-color: var(--ui-color-border-hover); }
.ui-select:focus { outline: none; border-color: var(--ui-color-primary); box-shadow: var(--ui-focus-ring); }
.ui-select:disabled { background-color: var(--ui-color-disabled-bg); border-color: var(--ui-color-disabled-border); color: var(--ui-color-disabled-text); cursor: not-allowed; }
</style>
Classe / attributDescription
.ui-selectà poser sur <select>
<option disabled selected>placeholder natif, sans attribut placeholder (qui n'existe pas sur <select>)
disabled (sur <option> ou <select>)attribut natif, inchangé

Input Number

CSS-first

Un <input type="number"> natif : le spinner +/- est fourni par le navigateur, avec les bornes min/max/step, sans bouton custom ni JavaScript.

<input type="number" class="ui-input-number" min="0" max="10" step="1" value="4" aria-label="Quantité" />
<input type="number" class="ui-input-number" min="0" max="20" step="0.5" value="2.5" aria-label="Montant" />
<input type="number" class="ui-input-number" value="3" disabled aria-label="Valeur désactivée" />

<style>
.ui-input-number {
  display: inline-block;
  width: 96px;
  height: 36px;
  padding: 0 10px;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  font: inherit;
  font-size: var(--ui-font-size-md);
  transition: border-color var(--ui-transition-fast);
}
.ui-input-number:hover { border-color: var(--ui-color-border-hover); }
.ui-input-number:focus { outline: none; border-color: var(--ui-color-primary); box-shadow: var(--ui-focus-ring); }
.ui-input-number:disabled { background: var(--ui-color-disabled-bg); border-color: var(--ui-color-disabled-border); color: var(--ui-color-disabled-text); cursor: not-allowed; }
</style>
Classe / attributDescription
.ui-input-numberstyle de base, à poser sur <input type="number">
min, maxbornes numériques natives
steppas d'incrémentation du spinner natif (défaut : 1)
disabledattribut natif, inchangé
aria-label (ou un <label for>)nom accessible du champ — indispensable, un placeholder seul ne suffit pas

Slider

CSS-first

Un <input type="range"> natif, dont le curseur et la piste sont stylés via ::-webkit-slider-thumb / ::-moz-range-thumb — le glisser à la souris et au clavier reste géré par le navigateur.

<input type="range" class="ui-slider" min="0" max="100" value="40" aria-label="Volume" />
<input type="range" class="ui-slider" min="0" max="200" step="10" value="70" aria-label="Distance" />
<input type="range" class="ui-slider" min="0" max="100" value="30" disabled aria-label="Valeur désactivée" />

<style>
.ui-slider {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 4px;
  border-radius: var(--ui-radius-full);
  background: var(--ui-color-border);
  cursor: pointer;
}
.ui-slider::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: var(--ui-radius-full);
  background: var(--ui-color-border);
}
.ui-slider::-moz-range-track {
  height: 4px;
  border-radius: var(--ui-radius-full);
  background: var(--ui-color-border);
}
.ui-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 16px;
  height: 16px;
  margin-top: -6px;
  border-radius: var(--ui-radius-full);
  background: var(--ui-color-primary);
  box-shadow: var(--ui-shadow-sm);
  transition: background var(--ui-transition-fast);
}
.ui-slider::-moz-range-thumb {
  width: 16px;
  height: 16px;
  border: none;
  border-radius: var(--ui-radius-full);
  background: var(--ui-color-primary);
  box-shadow: var(--ui-shadow-sm);
  transition: background var(--ui-transition-fast);
}
.ui-slider:hover::-webkit-slider-thumb { background: var(--ui-color-primary-hover); }
.ui-slider:hover::-moz-range-thumb { background: var(--ui-color-primary-hover); }
.ui-slider:focus-visible { outline: none; }
.ui-slider:focus-visible::-webkit-slider-thumb { box-shadow: var(--ui-focus-ring); }
.ui-slider:focus-visible::-moz-range-thumb { box-shadow: var(--ui-focus-ring); }
.ui-slider:disabled { cursor: not-allowed; opacity: 0.6; }
.ui-slider:disabled::-webkit-slider-thumb { background: var(--ui-color-disabled-text); }
.ui-slider:disabled::-moz-range-thumb { background: var(--ui-color-disabled-text); }
</style>
Classe / attributDescription
.ui-sliderstyle de base, à poser sur <input type="range">
min, max, stepplage et granularité natives
disabledattribut natif, inchangé
aria-label (ou un <label for>)nom accessible du curseur — sinon un lecteur d'écran annonce juste une valeur sans contexte

Rate

CSS-first

Notation par étoiles en CSS pur : des <input type="radio"> masqués, un <label> par étoile, et l'ordre DOM inversé (flex-direction: row-reverse) pour que les sélecteurs de fratrie CSS colorent les étoiles précédentes au survol et à la sélection.

<div class="ui-rate" role="radiogroup" aria-label="Note">
  <input type="radio" name="demo-rate" id="rate-5" value="5" />
  <label for="rate-5" aria-label="5 étoiles">★</label>
  <input type="radio" name="demo-rate" id="rate-4" value="4" />
  <label for="rate-4" aria-label="4 étoiles">★</label>
  <input type="radio" name="demo-rate" id="rate-3" value="3" checked />
  <label for="rate-3" aria-label="3 étoiles">★</label>
  <input type="radio" name="demo-rate" id="rate-2" value="2" />
  <label for="rate-2" aria-label="2 étoiles">★</label>
  <input type="radio" name="demo-rate" id="rate-1" value="1" />
  <label for="rate-1" aria-label="1 étoile">★</label>
</div>

<style>
.ui-rate { display: inline-flex; flex-direction: row-reverse; gap: 2px; }
.ui-rate input { position: absolute; opacity: 0; pointer-events: none; }
.ui-rate label { cursor: pointer; font-size: 22px; line-height: 1; color: var(--ui-color-border); }
.ui-rate input:checked ~ label,
.ui-rate label:hover,
.ui-rate label:hover ~ label,
.ui-rate input:checked ~ label:hover,
.ui-rate input:checked ~ label:hover ~ label { color: var(--ui-color-warning); }
.ui-rate:has(input:disabled) label { cursor: not-allowed; opacity: 0.6; }
</style>
Classe / attributDescription
.ui-rateconteneur, une paire input[type=radio] + label par étoile, dans l'ordre décroissant
checkedétoile initialement sélectionnée
disabled (sur chaque input)désactive toute la notation
nombre d'étoilesajouter/retirer des paires input/label pour changer l'échelle (ex. 10 étoiles)

Color Picker

CSS-first

Un <input type="color"> natif : la pastille est stylée en CSS (bordure, rayon), mais le sélecteur de couleur lui-même reste celui du système d'exploitation.

<input type="color" class="ui-color-picker" value="#3f7ce0" aria-label="Couleur principale" />
<input type="color" class="ui-color-picker" value="#dd4b4b" disabled aria-label="Couleur désactivée" />

<style>
.ui-color-picker {
  width: 44px;
  height: 36px;
  padding: 2px;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  cursor: pointer;
  transition: border-color var(--ui-transition-fast);
}
.ui-color-picker:hover { border-color: var(--ui-color-border-hover); }
.ui-color-picker:focus-visible { outline: none; box-shadow: var(--ui-focus-ring); }
.ui-color-picker:disabled { background: var(--ui-color-disabled-bg); border-color: var(--ui-color-disabled-border); cursor: not-allowed; }
.ui-color-picker::-webkit-color-swatch-wrapper { padding: 0; }
.ui-color-picker::-webkit-color-swatch { border: none; border-radius: var(--ui-radius-sm); }
.ui-color-picker::-moz-color-swatch { border: none; border-radius: var(--ui-radius-sm); }
</style>
Classe / attributDescription
.ui-color-pickerstyle de base, à poser sur <input type="color">
valuecouleur au format hexadécimal, ex. #3f7ce0
disabledattribut natif, inchangé
aria-label (ou un <label for>)nom accessible du sélecteur de couleur

Autocomplete

CSS-first

Un <input> classique associé à une <datalist> via l'attribut list : le filtrage des suggestions est géré par le navigateur, sans JavaScript. Compromis assumé du "natif d'abord" : l'apparence de la liste de suggestions elle-même (police, espacement, survol) reste imposée par le navigateur et ne peut pas être stylée avec nos tokens.

<label class="ui-label" for="country">Pays</label>
<input class="ui-input" id="country" list="country-options" placeholder="Rechercher un pays" />
<datalist id="country-options">
  <option value="France"></option>
  <option value="Belgique"></option>
  <option value="Suisse"></option>
  <option value="Canada"></option>
  <option value="Maroc"></option>
</datalist>
Classe / attributDescription
.ui-inputmême style de base que le composant Input
listrelie l'<input> à l'id de la <datalist>
<datalist> / <option>liste des suggestions, filtrées nativement par le navigateur à la frappe

Date Picker

CSS-first

Un <input type="date"> natif : le calendrier custom est abandonné au profit du picker fourni par le navigateur (et adapté à la langue/région de l'utilisateur), sans JavaScript.

<label class="ui-label" for="delivery-date">Date de livraison</label>
<input type="date" class="ui-input" id="delivery-date" />
Classe / attributDescription
.ui-inputmême style de base que le composant Input
valuedate au format AAAA-MM-JJ
min, maxbornes de dates sélectionnables, attributs natifs
disabledattribut natif, inchangé
<label for>nom accessible du champ, indispensable

Time Picker

CSS-first

Un <input type="time"> natif : le picker à deux colonnes custom est abandonné au profit du sélecteur d'heure du navigateur, sans JavaScript.

<label class="ui-label" for="appointment-time">Heure de rendez-vous</label>
<input type="time" class="ui-input" id="appointment-time" step="300" />
Classe / attributDescription
.ui-inputmême style de base que le composant Input
valueheure au format HH:MM
stepgranularité en secondes, ex. 300 pour un pas de 5 minutes
disabledattribut natif, inchangé
<label for>nom accessible du champ, indispensable

Upload

CSS-first

Un <input type="file"> natif, avec le bouton de sélection stylé via ::file-selector-button — sélecteur de fichiers et gestion de la liste par le système, zéro JavaScript. Le glisser-déposer et une liste de fichiers custom sont des améliorations qui nécessiteraient du JavaScript et ne sont pas couvertes ici.

<label class="ui-label" for="attachments">Pièces jointes</label>
<input type="file" class="ui-upload" id="attachments" multiple accept="image/*,.pdf" />

<style>
.ui-upload {
  display: block;
  width: 100%;
  padding: 8px 12px;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text-secondary);
  font: inherit;
  font-size: var(--ui-font-size-sm);
  cursor: pointer;
  transition: border-color var(--ui-transition-fast);
}
.ui-upload:hover { border-color: var(--ui-color-border-hover); }
.ui-upload:focus-visible { outline: none; box-shadow: var(--ui-focus-ring); }
.ui-upload:disabled { background: var(--ui-color-disabled-bg); border-color: var(--ui-color-disabled-border); color: var(--ui-color-disabled-text); cursor: not-allowed; }
.ui-upload::file-selector-button {
  margin-right: 12px;
  padding: 6px 12px;
  border: none;
  border-radius: var(--ui-radius-sm);
  background: var(--ui-color-primary-soft);
  color: var(--ui-color-primary);
  font: inherit;
  font-size: var(--ui-font-size-sm);
  cursor: pointer;
  transition: background var(--ui-transition-fast), color var(--ui-transition-fast);
}
.ui-upload::file-selector-button:hover { background: var(--ui-color-primary); color: var(--ui-color-text-on-primary); }
.ui-upload::-webkit-file-upload-button {
  margin-right: 12px;
  padding: 6px 12px;
  border: none;
  border-radius: var(--ui-radius-sm);
  background: var(--ui-color-primary-soft);
  color: var(--ui-color-primary);
  font: inherit;
  font-size: var(--ui-font-size-sm);
  cursor: pointer;
  transition: background var(--ui-transition-fast), color var(--ui-transition-fast);
}
.ui-upload::-webkit-file-upload-button:hover { background: var(--ui-color-primary); color: var(--ui-color-text-on-primary); }
.ui-upload:disabled::file-selector-button,
.ui-upload:disabled::-webkit-file-upload-button { background: var(--ui-color-disabled-bg); color: var(--ui-color-disabled-text); cursor: not-allowed; }
</style>
Classe / attributDescription
.ui-uploadstyle de base, à poser sur <input type="file">
multipleautorise la sélection de plusieurs fichiers
accepttypes de fichiers acceptés, attribut natif inchangé
disabledattribut natif, inchangé
<label for>nom accessible du champ, indispensable

Badge

CSS-first

Un badge est un <span> positionné en absolu sur le coin d'un élément grâce à .ui-badge-wrap — aucun JavaScript, même pour la valeur 99+ qui est simplement écrite en dur dans le HTML.

6 99+
<span class="ui-badge-wrap">
  <button class="ui-btn">Messages</button>
  <span class="ui-badge">6</span>
</span>

<span class="ui-badge-wrap">
  <button class="ui-btn">Notifications</button>
  <span class="ui-badge">99+</span>
</span>

<span class="ui-badge-wrap">
  <button class="ui-btn">Alertes</button>
  <span class="ui-badge ui-badge--dot"></span>
</span>

<style>
.ui-badge-wrap {
  position: relative;
  display: inline-flex;
}
.ui-badge {
  position: absolute;
  top: -6px;
  right: -6px;
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--ui-radius-full);
  background: var(--ui-color-danger);
  color: var(--ui-color-text-on-primary);
  font-size: var(--ui-font-size-sm);
  line-height: 1;
  box-shadow: 0 0 0 2px var(--ui-color-bg-elevated);
}
.ui-badge--dot {
  top: -2px;
  right: -2px;
  min-width: 8px;
  width: 8px;
  height: 8px;
  padding: 0;
}
</style>
Classe / attributDescription
.ui-badge-wrapconteneur relatif à poser autour de l'élément porteur du badge
.ui-badgepastille positionnée en absolu sur le coin du conteneur
.ui-badge--dotvariante point, sans contenu texte
valeur max (ex. 99+)écrite statiquement dans le HTML, aucun calcul JS

Tag

CSS + JS minimal

Un <span class="ui-tag"> stylé en CSS pur pour les variantes de couleur. Seule la version closable a besoin d'une ligne de JavaScript pour retirer le tag du DOM au clic sur sa croix.

Primary Success Warning Danger Round
<span class="ui-tag ui-tag--primary">Primary</span>
<span class="ui-tag ui-tag--success">Success</span>
<span class="ui-tag ui-tag--warning">Warning</span>
<span class="ui-tag ui-tag--danger">Danger<button class="ui-tag__close" aria-label="Supprimer">×</button></span>
<span class="ui-tag ui-tag--round">Round</span>

<style>
.ui-tag {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  height: 22px;
  padding: 0 8px;
  border-radius: var(--ui-radius-sm);
  background: var(--ui-color-info-soft);
  color: var(--ui-color-info);
  font-size: var(--ui-font-size-sm);
  line-height: 1;
}
.ui-tag--primary { background: var(--ui-color-primary-soft); color: var(--ui-color-primary); }
.ui-tag--success { background: var(--ui-color-success-soft); color: var(--ui-color-success); }
.ui-tag--warning { background: var(--ui-color-warning-soft); color: var(--ui-color-warning); }
.ui-tag--danger { background: var(--ui-color-danger-soft); color: var(--ui-color-danger); }
.ui-tag--round { border-radius: var(--ui-radius-full); }

.ui-tag__close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 14px;
  height: 14px;
  margin: 0;
  padding: 0;
  border: none;
  background: none;
  color: inherit;
  font: inherit;
  line-height: 1;
  cursor: pointer;
  opacity: 0.7;
}
.ui-tag__close:hover { opacity: 1; }
</style>

<script>
  document.querySelectorAll('.ui-tag__close').forEach(function (btn) {
    btn.addEventListener('click', function () {
      btn.closest('.ui-tag').remove()
    })
  })
</script>
Classe / attributDescription
.ui-tagbase, à poser sur un <span>
.ui-tag--primary / --success / --warning / --dangervariante de couleur
.ui-tag--roundcoins en pilule
.ui-tag__closebouton de fermeture ; un clic retire le .ui-tag parent via btn.closest('.ui-tag').remove()

Card

CSS-first

Une <article> native découpée en <header>, corps et <footer> — une structure sémantique qui remplace les slots, sans JavaScript.

Titre de la carte
Contenu principal de la carte, avec du texte libre.
<article class="ui-card">
  <header class="ui-card__header">Titre de la carte</header>
  <div class="ui-card__body">Contenu principal de la carte, avec du texte libre.</div>
  <footer class="ui-card__footer">
    <button class="ui-btn ui-btn--sm">Annuler</button>
    <button class="ui-btn ui-btn--primary ui-btn--sm">Valider</button>
  </footer>
</article>

<style>
.ui-card {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-lg);
  background: var(--ui-color-bg-elevated);
  box-shadow: var(--ui-shadow-sm);
  overflow: hidden;
}
.ui-card__header {
  padding: var(--ui-spacing-md) var(--ui-spacing-lg);
  border-bottom: 1px solid var(--ui-color-border);
  font-size: var(--ui-font-size-md);
  font-weight: 600;
  color: var(--ui-color-text);
}
.ui-card__body {
  padding: var(--ui-spacing-lg);
  color: var(--ui-color-text-secondary);
  font-size: var(--ui-font-size-md);
  line-height: var(--ui-line-height);
}
.ui-card__footer {
  display: flex;
  justify-content: flex-end;
  gap: var(--ui-spacing-sm);
  padding: var(--ui-spacing-md) var(--ui-spacing-lg);
  border-top: 1px solid var(--ui-color-border);
  background: var(--ui-color-bg-subtle);
}
</style>
Classe / attributDescription
.ui-cardconteneur, à poser sur <article> (ou tout élément bloc)
.ui-card__headeren-tête, à poser sur <header>
.ui-card__bodycorps de la carte, contenu libre
.ui-card__footerpied, à poser sur <footer>, aligne ses enfants à droite

Avatar

CSS-first

Un <span> avec des initiales ou un <img> avec une photo, tous deux stylés par .ui-avatar — pas de JavaScript.

TH TH TH AB
<span class="ui-avatar ui-avatar--sm">TH</span>
<span class="ui-avatar">TH</span>
<span class="ui-avatar ui-avatar--lg">TH</span>
<span class="ui-avatar ui-avatar--square">AB</span>

<img class="ui-avatar" src="/photo.jpg" alt="Thomas" />

<style>
.ui-avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border-radius: var(--ui-radius-full);
  background: var(--ui-color-primary-soft);
  color: var(--ui-color-primary);
  font-size: var(--ui-font-size-sm);
  font-weight: 600;
  line-height: 1;
  object-fit: cover;
  overflow: hidden;
}
.ui-avatar--sm { width: 26px; height: 26px; font-size: 0.75rem; }
.ui-avatar--lg { width: 48px; height: 48px; font-size: var(--ui-font-size-lg); }
.ui-avatar--square { border-radius: var(--ui-radius-md); }
</style>
Classe / attributDescription
.ui-avatarbase, à poser sur <span> (initiales) ou <img> (photo)
.ui-avatar--sm / --lgtaille
.ui-avatar--squareforme carrée aux coins arrondis plutôt que ronde
src, altattributs natifs de <img>, inchangés

Icon

CSS-first

Chaque icône est un <svg> inline au style "feather", avec la classe .ui-icon. La variante --spin anime la rotation en CSS pur, sans JavaScript. Une collection de départ est disponible plus bas : clique une icône pour copier son SVG.

<!-- search -->
<svg class="ui-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>

<!-- user -->
<svg class="ui-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>

<!-- calendar -->
<svg class="ui-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>

<!-- warning -->
<svg class="ui-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>

<!-- success -->
<svg class="ui-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>

<!-- loading -->
<svg class="ui-icon ui-icon--spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="2" x2="12" y2="6"></line><line x1="12" y1="18" x2="12" y2="22"></line><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line><line x1="2" y1="12" x2="6" y2="12"></line><line x1="18" y1="12" x2="22" y2="12"></line><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line></svg>

<style>
.ui-icon {
  color: var(--ui-color-text-secondary);
  vertical-align: middle;
  flex-shrink: 0;
}
.ui-icon--spin {
  animation: ui-icon-spin 1s linear infinite;
}
@keyframes ui-icon-spin {
  to { transform: rotate(360deg); }
}
</style>
Classe / attributDescription
.ui-iconbase, à poser sur le <svg>
.ui-icon--spinanime une rotation continue en CSS, utile pour l'icône loading
width / heighttaille de l'icône, portée directement sur le <svg> (16 à 20px selon le contexte)
stroke="currentColor"hérite la couleur du texte environnant

Collection — clique une icône pour copier son SVG

Divider

CSS-first

Un <hr> natif pour le séparateur horizontal, un <div> avec deux traits en ::before/::after pour le séparateur à texte, et un <span> pour le séparateur vertical inline — pas de JavaScript.



Ou bien

Texte avec séparateur vertical

<hr class="ui-divider" />
<hr class="ui-divider ui-divider--dashed" />
<div class="ui-divider ui-divider--text">Ou bien</div>
<p>Texte <span class="ui-divider ui-divider--vertical"></span> avec séparateur vertical</p>

<style>
hr.ui-divider {
  margin: var(--ui-spacing-lg) 0;
  border: none;
  border-top: 1px solid var(--ui-color-border);
}
hr.ui-divider--dashed {
  border-top-style: dashed;
}

.ui-divider--text {
  display: flex;
  align-items: center;
  gap: var(--ui-spacing-md);
  margin: var(--ui-spacing-lg) 0;
  color: var(--ui-color-text-secondary);
  font-size: var(--ui-font-size-sm);
}
.ui-divider--text::before,
.ui-divider--text::after {
  content: '';
  flex: 1;
  height: 1px;
  background: var(--ui-color-border);
}

.ui-divider--vertical {
  display: inline-block;
  width: 1px;
  height: 0.9em;
  margin: 0 var(--ui-spacing-sm);
  background: var(--ui-color-border);
  vertical-align: middle;
}
</style>
Classe / attributDescription
.ui-dividerbase, à poser sur <hr>, <div> ou <span> selon l'usage
.ui-divider--dashedtrait en pointillés (sur <hr>)
.ui-divider--textséparateur avec texte centré, deux traits générés en ::before/::after (sur <div>)
.ui-divider--verticalséparateur vertical inline, à poser sur <span>

Collapse

CSS-first

Un <details>/<summary> natif stylé en .ui-collapse. Donner le même attribut name à plusieurs <details> force un mode accordéon exclusif (un seul panneau ouvert) nativement, sans JavaScript.

Qu'est-ce que thomias-ui ?
Un kit de snippets CSS à coller sur des éléments HTML natifs, sans dépendance ni build.
Puis-je le personnaliser ?
Oui, entièrement via les variables CSS de tokens.css.
Faut-il un framework ?
Non, les classes fonctionnent dans n'importe quelle page HTML.
<details class="ui-collapse" name="demo-collapse" open>
  <summary>Question 1</summary>
  <div class="ui-collapse__body">Réponse 1</div>
</details>
<details class="ui-collapse" name="demo-collapse">
  <summary>Question 2</summary>
  <div class="ui-collapse__body">Réponse 2</div>
</details>

<style>
.ui-collapse {
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  overflow: hidden;
}
.ui-collapse + .ui-collapse { margin-top: var(--ui-spacing-sm); }
.ui-collapse > summary {
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--ui-spacing-md) var(--ui-spacing-lg);
  font-size: var(--ui-font-size-md);
  color: var(--ui-color-text);
  cursor: pointer;
  user-select: none;
  transition: background var(--ui-transition-fast);
}
.ui-collapse > summary::-webkit-details-marker { display: none; }
.ui-collapse > summary::after {
  content: '';
  width: 8px;
  height: 8px;
  flex-shrink: 0;
  border-right: 2px solid var(--ui-color-text-secondary);
  border-bottom: 2px solid var(--ui-color-text-secondary);
  transform: rotate(45deg);
  transition: transform var(--ui-transition-fast);
}
.ui-collapse[open] > summary::after { transform: rotate(-135deg); }
.ui-collapse > summary:hover { background: var(--ui-color-bg-subtle); }
.ui-collapse > summary:focus-visible { outline: none; box-shadow: var(--ui-focus-ring); }
.ui-collapse__body {
  padding: 0 var(--ui-spacing-lg) var(--ui-spacing-lg);
  color: var(--ui-color-text-secondary);
  font-size: var(--ui-font-size-md);
  line-height: var(--ui-line-height);
}
</style>
Classe / attributDescription
.ui-collapseà poser sur <details>
.ui-collapse__bodyconteneur du contenu, dans un <div> après <summary>
name (attribut natif)partagé entre plusieurs <details> : force un mode accordéon exclusif
open (attribut natif)état ouvert initial

Timeline

CSS-first

Frise chronologique verticale en .ui-timeline, sur une liste <ol> native. Le point et la ligne verticale sont posés en CSS pur via ::before et ::after, aucun JavaScript requis.

  1. Commande reçue

  2. En préparation

  3. Retard du livreur signalé

  4. Livraison prévue

<ol class="ui-timeline">
  <li class="ui-timeline-item ui-timeline-item--success">
    <time>10:00</time>
    <p>Commande reçue</p>
  </li>
  <li class="ui-timeline-item ui-timeline-item--warning">
    <time>11:30</time>
    <p>Retard signalé</p>
  </li>
</ol>

<style>
.ui-timeline { list-style: none; margin: 0; padding: 0; }
.ui-timeline-item {
  position: relative;
  padding: 0 0 var(--ui-spacing-xl) var(--ui-spacing-xl);
}
.ui-timeline-item::before {
  content: '';
  position: absolute;
  left: 0;
  top: 4px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--ui-color-info);
}
.ui-timeline-item::after {
  content: '';
  position: absolute;
  left: 4px;
  top: 14px;
  bottom: 0;
  width: 2px;
  background: var(--ui-color-border);
}
.ui-timeline-item:last-child::after { display: none; }
.ui-timeline-item time {
  display: block;
  font-size: var(--ui-font-size-sm);
  color: var(--ui-color-text-secondary);
  margin-bottom: 2px;
}
.ui-timeline-item p { margin: 0; color: var(--ui-color-text); font-size: var(--ui-font-size-md); }

.ui-timeline-item--success::before { background: var(--ui-color-success); }
.ui-timeline-item--primary::before { background: var(--ui-color-primary); }
.ui-timeline-item--warning::before { background: var(--ui-color-warning); }
.ui-timeline-item--danger::before { background: var(--ui-color-danger); }
.ui-timeline-item--info::before { background: var(--ui-color-info); }
</style>
ClasseDescription
.ui-timelineà poser sur <ol>
.ui-timeline-itemà poser sur chaque <li>, avec un <time> et un <p>
.ui-timeline-item--primary / --success / --warning / --danger / --infocouleur du point

Skeleton

CSS-first

Aperçu de chargement en .ui-skeleton, avec un dégradé animé en CSS pur (@keyframes). Basculer du squelette vers le contenu réel dans une appli demande simplement de retirer la classe en JavaScript une fois les données arrivées.

TB
Thomas Boucks

Contenu réel, affiché une fois les données chargées.

<div class="ui-skeleton">
  <span class="ui-skeleton__avatar"></span>
  <span class="ui-skeleton__line"></span>
  <span class="ui-skeleton__line"></span>
  <span class="ui-skeleton__line ui-skeleton__line--short"></span>
</div>

<style>
.ui-skeleton { display: flex; flex-direction: column; gap: var(--ui-spacing-sm); }
.ui-skeleton__avatar,
.ui-skeleton__line {
  display: block;
  background: linear-gradient(90deg, var(--ui-color-bg-subtle) 25%, var(--ui-color-border) 37%, var(--ui-color-bg-subtle) 63%);
  background-size: 400% 100%;
  animation: ui-skeleton-shimmer 1.4s ease infinite;
}
.ui-skeleton__avatar { width: 48px; height: 48px; border-radius: 50%; flex-shrink: 0; }
.ui-skeleton__line { height: 12px; border-radius: var(--ui-radius-sm); }
.ui-skeleton__line--short { width: 60%; }

@keyframes ui-skeleton-shimmer {
  0% { background-position: 100% 50%; }
  100% { background-position: 0 50%; }
}
</style>
ClasseDescription
.ui-skeletonconteneur flex vertical
.ui-skeleton__avatarrond de chargement
.ui-skeleton__lineligne de texte factice
.ui-skeleton__line--shortligne raccourcie (dernière ligne d'un paragraphe)

Empty

CSS-first

État vide en .ui-empty, pour une liste ou un résultat de recherche sans donnée. Icône SVG inline, aucun JavaScript requis.

Aucun résultat trouvé.

<div class="ui-empty">
  <svg class="ui-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
  <p>Aucun résultat trouvé.</p>
  <button class="ui-btn ui-btn--primary ui-btn--sm">Réessayer</button>
</div>

<style>
.ui-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--ui-spacing-md);
  padding: var(--ui-spacing-xl);
  text-align: center;
  color: var(--ui-color-text-secondary);
}
.ui-empty .ui-icon { color: var(--ui-color-text-placeholder); }
.ui-empty p { margin: 0; font-size: var(--ui-font-size-md); }
</style>
ClasseDescription
.ui-emptyconteneur centré (icône, texte, action)
.ui-iconillustration SVG inline, style "feather"
action librebouton ou lien optionnel après le texte

Statistic

CSS-first

Met en avant un chiffre clé en .ui-statistic, avec un préfixe ou un suffixe optionnel. Structure HTML pure, aucun JavaScript requis.

Chiffre d'affaires

128 430

Taux de conversion

4,86%

<div class="ui-statistic">
  <p class="ui-statistic__title">Chiffre d'affaires</p>
  <p class="ui-statistic__value"><span class="ui-statistic__affix">€</span>128 430</p>
</div>
<div class="ui-statistic">
  <p class="ui-statistic__title">Taux de conversion</p>
  <p class="ui-statistic__value">4,86<span class="ui-statistic__affix">%</span></p>
</div>

<style>
.ui-statistic { display: flex; flex-direction: column; gap: 4px; }
.ui-statistic__title { margin: 0; font-size: var(--ui-font-size-sm); color: var(--ui-color-text-secondary); }
.ui-statistic__value {
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: 4px;
  font-size: 1.75rem;
  font-weight: 600;
  color: var(--ui-color-text);
}
.ui-statistic__affix { font-size: var(--ui-font-size-md); font-weight: 500; color: var(--ui-color-text-secondary); }
</style>
ClasseDescription
.ui-statisticconteneur (titre + valeur)
.ui-statistic__titlelibellé affiché au-dessus de la valeur
.ui-statistic__valuevaleur mise en avant
.ui-statistic__affixunité ou symbole, placé avant ou après la valeur

Descriptions

CSS-first

Grille de paires libellé / valeur en .ui-descriptions, sur une liste <dl> native pilotée par la variable --ui-descriptions-columns. CSS Grid pur, aucun JavaScript requis.

Nom
Thomas Boucks
Email
alice.martin@example.com
Statut
Actif
Inscription
12/03/2024
<dl class="ui-descriptions" style="--ui-descriptions-columns:2">
  <div class="ui-descriptions__item">
    <dt>Nom</dt>
    <dd>Thomas Boucks</dd>
  </div>
  <div class="ui-descriptions__item">
    <dt>Statut</dt>
    <dd><span class="ui-tag ui-tag--success">Actif</span></dd>
  </div>
</dl>

<style>
.ui-descriptions {
  display: grid;
  grid-template-columns: repeat(var(--ui-descriptions-columns, 2), 1fr);
  gap: var(--ui-spacing-md) var(--ui-spacing-xl);
  margin: 0;
}
.ui-descriptions__item { display: flex; flex-direction: column; gap: 2px; }
.ui-descriptions__item dt { margin: 0; font-size: var(--ui-font-size-sm); color: var(--ui-color-text-secondary); }
.ui-descriptions__item dd { margin: 0; font-size: var(--ui-font-size-md); color: var(--ui-color-text); }

@media (max-width: 640px) {
  .ui-descriptions { grid-template-columns: 1fr; }
}
</style>
Classe / variableDescription
.ui-descriptionsà poser sur <dl>
--ui-descriptions-columnsvariable inline pilotant le nombre de colonnes de la grille (empilée en dessous de 640px)
.ui-descriptions__itempaire <dt> / <dd>

Popover

CSS-first

Contenu HTML libre affiché au clic grâce à l'API native popover (Baseline 2024) : popovertarget ouvre et ferme le panneau sans JavaScript. Un déclenchement au survol demanderait quelques lignes de JS (showPopover() / hidePopover() sur mouseenter / mouseleave).

Titre du popover

Contenu libre, avec du HTML.

<button popovertarget="pop-demo" class="ui-btn">Cliquer</button>
<div id="pop-demo" popover class="ui-popover">
  <strong>Titre</strong>
  <p>Contenu libre, avec du HTML.</p>
</div>

<style>
.ui-popover {
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  padding: var(--ui-spacing-md) var(--ui-spacing-lg);
  box-shadow: var(--ui-shadow-md);
  max-width: 280px;
}
.ui-popover strong { display: block; margin-bottom: 4px; }
.ui-popover p { margin: 0; color: var(--ui-color-text-secondary); font-size: var(--ui-font-size-md); }
</style>
Attribut / classeDescription
popoverattribut natif : transforme l'élément en panneau top-layer, masqué par défaut
popovertargetsur le bouton déclencheur : id de l'élément popover à ouvrir/fermer
.ui-popoverhabillage visuel du panneau (bordure, ombre, fond)

Alert

CSS-first

Un conteneur .ui-alert fermable en CSS pur grâce à l'astuce du <input type="checkbox" hidden> couplé au sélecteur :has() : aucun JavaScript n'est nécessaire, même pour la fermeture.

Succès

L'opération s'est déroulée correctement.

Attention

Vérifie les champs avant de continuer.

Erreur

Une erreur est survenue lors de l'envoi.

<div class="ui-alert ui-alert--success">
  <input type="checkbox" id="alert-demo-1" class="ui-alert__toggle" hidden />
  <div class="ui-alert__body">
    <strong class="ui-alert__title">Succès</strong>
    <p>L'opération s'est déroulée correctement.</p>
  </div>
  <label for="alert-demo-1" class="ui-alert__close" aria-label="Fermer">×</label>
</div>

<div class="ui-alert ui-alert--error">
  <div class="ui-alert__body">
    <strong class="ui-alert__title">Erreur</strong>
    <p>Une erreur est survenue lors de l'envoi.</p>
  </div>
</div>

<style>
.ui-alert {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: var(--ui-spacing-sm);
  padding: var(--ui-spacing-md) var(--ui-spacing-lg);
  border-radius: var(--ui-radius-md);
  border: 1px solid transparent;
  font-size: var(--ui-font-size-md);
  line-height: var(--ui-line-height);
}
.ui-alert:has(.ui-alert__toggle:checked) { display: none; }

.ui-alert__body { flex: 1; }
.ui-alert__title { display: block; margin-bottom: 2px; }
.ui-alert__body p { margin: 0; }
.ui-alert__close {
  flex-shrink: 0;
  cursor: pointer;
  font-size: 1.1em;
  line-height: 1;
  color: inherit;
  opacity: 0.6;
  transition: opacity var(--ui-transition-fast);
}
.ui-alert__close:hover { opacity: 1; }

.ui-alert--success { background: var(--ui-color-success-soft); border-color: var(--ui-color-success); color: var(--ui-color-success); }
.ui-alert--warning { background: var(--ui-color-warning-soft); border-color: var(--ui-color-warning); color: var(--ui-color-warning); }
.ui-alert--error { background: var(--ui-color-danger-soft); border-color: var(--ui-color-danger); color: var(--ui-color-danger); }
</style>
Classe / attributDescription
.ui-alertconteneur de base
.ui-alert--success / --warning / --errorvariante de couleur
.ui-alert__titletitre optionnel (élément <strong>)
input.ui-alert__toggle (type="checkbox" hidden)pilote la fermeture ; à omettre pour une alerte non fermable
.ui-alert__close (<label for="...">)déclencheur de fermeture, reste cliquable même une fois l'alerte masquée
:has(.ui-alert__toggle:checked)sélecteur CSS qui masque toute l'alerte à la coche — zéro JavaScript

Progress

CSS-first

Un <progress> natif stylé en .ui-progress. L'état indéterminé est celui du navigateur (pas d'attribut value, animation automatique) ; le motif rayé s'obtient avec un repeating-linear-gradient sur ::-webkit-progress-value.

42%
80%
<div class="ui-progress-wrap">
  <progress class="ui-progress" value="42" max="100"></progress>
  <span class="ui-progress__text">42%</span>
</div>

<progress class="ui-progress ui-progress--success" value="80" max="100"></progress>
<progress class="ui-progress ui-progress--striped" value="60" max="100"></progress>

<!-- état indéterminé : simplement sans attribut value -->
<progress class="ui-progress"></progress>

<style>
.ui-progress {
  appearance: none;
  -webkit-appearance: none;
  display: block;
  width: 100%;
  height: 8px;
  border: none;
  border-radius: var(--ui-radius-full);
  overflow: hidden;
  background: var(--ui-color-bg-subtle);
}
.ui-progress::-webkit-progress-bar { background: var(--ui-color-bg-subtle); border-radius: var(--ui-radius-full); }
.ui-progress::-webkit-progress-value { background: var(--ui-color-primary); border-radius: var(--ui-radius-full); transition: width var(--ui-transition); }
.ui-progress::-moz-progress-bar { background: var(--ui-color-primary); border-radius: var(--ui-radius-full); }

.ui-progress--success::-webkit-progress-value { background: var(--ui-color-success); }
.ui-progress--success::-moz-progress-bar { background: var(--ui-color-success); }
.ui-progress--warning::-webkit-progress-value { background: var(--ui-color-warning); }
.ui-progress--warning::-moz-progress-bar { background: var(--ui-color-warning); }
.ui-progress--error::-webkit-progress-value { background: var(--ui-color-danger); }
.ui-progress--error::-moz-progress-bar { background: var(--ui-color-danger); }

.ui-progress--striped::-webkit-progress-value {
  background-color: var(--ui-color-primary);
  background-image: repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.3) 0 8px, transparent 8px 16px);
}
.ui-progress--striped::-moz-progress-bar {
  background-color: var(--ui-color-primary);
  background-image: repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.3) 0 8px, transparent 8px 16px);
}

.ui-progress-wrap { display: flex; align-items: center; gap: var(--ui-spacing-sm); }
.ui-progress-wrap .ui-progress { flex: 1; }
.ui-progress__text { min-width: 2.5em; text-align: right; font-size: var(--ui-font-size-sm); color: var(--ui-color-text-secondary); }
</style>
Classe / attributDescription
.ui-progressstyle de base, à poser sur <progress>
value / maxattributs natifs qui déterminent le remplissage
sans valueétat indéterminé natif du navigateur, animation automatique
.ui-progress--success / --warning / --errorcouleur de statut
.ui-progress--stripedmotif rayé via repeating-linear-gradient
.ui-progress-wrap / .ui-progress__textconteneur optionnel pour afficher un texte de pourcentage à côté de la barre

Toast

JS requis

Notification impérative déclenchée via une fonction autonome showToast() : aucune balise persistante ne peut représenter une notification transitoire en HTML/CSS pur, c'est le seul composant du lot qui a vraiment besoin de JavaScript.

<button class="ui-btn ui-btn--primary" id="toast-info">Info</button>
<button class="ui-btn ui-btn--success" id="toast-success">Succès</button>
<button class="ui-btn ui-btn--danger" id="toast-error">Erreur</button>

<style>
.ui-toast-region {
  position: fixed;
  top: var(--ui-spacing-xl);
  right: var(--ui-spacing-xl);
  z-index: 9999;
  display: flex;
  flex-direction: column;
  gap: var(--ui-spacing-sm);
  pointer-events: none;
}
.ui-toast {
  pointer-events: auto;
  min-width: 260px;
  max-width: 360px;
  padding: var(--ui-spacing-md) var(--ui-spacing-lg);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  border: 1px solid var(--ui-color-border);
  border-left: 3px solid var(--ui-color-info);
  box-shadow: var(--ui-shadow-md);
  color: var(--ui-color-text);
  font-size: var(--ui-font-size-md);
  animation: ui-toast-in var(--ui-transition) ease;
}
.ui-toast__title { display: block; margin-bottom: 2px; }
.ui-toast__message { margin: 0; color: var(--ui-color-text-secondary); }

.ui-toast--info { border-left-color: var(--ui-color-info); }
.ui-toast--success { border-left-color: var(--ui-color-success); }
.ui-toast--warning { border-left-color: var(--ui-color-warning); }
.ui-toast--error { border-left-color: var(--ui-color-danger); }

.ui-toast--leaving { animation: ui-toast-out var(--ui-transition) ease forwards; }

@keyframes ui-toast-in {
  from { opacity: 0; transform: translateX(16px); }
  to { opacity: 1; transform: translateX(0); }
}
@keyframes ui-toast-out {
  to { opacity: 0; transform: translateX(16px); }
}
</style>

<script>
function showToast({ type = 'info', title = '', message = '', duration = 3000 } = {}) {
  let region = document.getElementById('ui-toast-region')
  if (!region) {
    region = document.createElement('div')
    region.id = 'ui-toast-region'
    region.className = 'ui-toast-region'
    document.body.appendChild(region)
  }
  const toast = document.createElement('div')
  toast.className = 'ui-toast ui-toast--' + type
  toast.innerHTML = (title ? '<strong class="ui-toast__title">' + title + '</strong>' : '') + '<p class="ui-toast__message">' + message + '</p>'
  region.appendChild(toast)
  const remove = () => {
    toast.classList.add('ui-toast--leaving')
    toast.addEventListener('animationend', () => toast.remove(), { once: true })
  }
  setTimeout(remove, duration)
}

document.getElementById('toast-info').addEventListener('click', () => {
  showToast({ type: 'info', title: 'Info', message: 'Une information à connaître.' })
})
document.getElementById('toast-success').addEventListener('click', () => {
  showToast({ type: 'success', title: 'Enregistré', message: 'Vos modifications ont été sauvegardées.' })
})
document.getElementById('toast-error').addEventListener('click', () => {
  showToast({ type: 'error', title: 'Erreur', message: "Une erreur est survenue lors de l'envoi." })
})
</script>
OptionDescription
typeinfo, success, warning, error
title, messagetextes affichés
durationdurée en ms avant fermeture auto (défaut : 3000)
#ui-toast-regionconteneur créé une fois, en position: fixed, qui empile les notifications

Table

CSS + JS minimal

Un vrai <table> natif : lignes et colonnes écrites en dur, zébrage et bordures gérés en CSS pur via .ui-table. Un petit script optionnel ajoute le tri au clic sur les en-têtes.

Rôle
AliceDesigner32
BrunoDéveloppeur28
ChloéChef de projet41
<table class="ui-table ui-table--stripe" id="table">
  <thead>
    <tr>
      <th aria-sort="none"><button type="button" class="ui-table__sort" data-sort>Nom</button></th>
      <th>Rôle</th>
      <th aria-sort="none" style="width:80px"><button type="button" class="ui-table__sort" data-sort>Âge</button></th>
    </tr>
  </thead>
  <tbody>
    <tr><td>Alice</td><td>Designer</td><td>32</td></tr>
    <tr><td>Bruno</td><td>Développeur</td><td>28</td></tr>
    <tr><td>Chloé</td><td>Chef de projet</td><td>41</td></tr>
  </tbody>
</table>

<style>
.ui-table { width: 100%; border-collapse: collapse; font-size: var(--ui-font-size-md); color: var(--ui-color-text); }
.ui-table th, .ui-table td { padding: var(--ui-spacing-sm) var(--ui-spacing-md); text-align: left; border-bottom: 1px solid var(--ui-color-border); }
.ui-table thead th { color: var(--ui-color-text-secondary); font-weight: 600; }
.ui-table--stripe tbody tr:nth-child(even) { background: var(--ui-color-bg-subtle); }
.ui-table__sort {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  border: none;
  background: none;
  padding: 0;
  margin: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
}
.ui-table__sort:focus-visible { outline: none; box-shadow: var(--ui-focus-ring); border-radius: 4px; }
.ui-table__sort::after { content: '↕'; font-size: 0.75em; opacity: 0.5; }
th[aria-sort="ascending"] .ui-table__sort::after { content: '↑'; opacity: 1; color: var(--ui-color-primary); }
th[aria-sort="descending"] .ui-table__sort::after { content: '↓'; opacity: 1; color: var(--ui-color-primary); }
</style>

<script>
  document.querySelectorAll('#table button[data-sort]').forEach((btn) => {
    btn.addEventListener('click', () => {
      const th = btn.closest('th')
      const index = [...th.parentElement.children].indexOf(th)
      const table = th.closest('table')
      const asc = th.getAttribute('aria-sort') !== 'ascending'
      table.querySelectorAll('th[aria-sort]').forEach((h) => h.setAttribute('aria-sort', 'none'))
      th.setAttribute('aria-sort', asc ? 'ascending' : 'descending')
      const rows = Array.from(table.tBodies[0].rows).sort((a, b) => {
        const x = a.children[index].textContent.trim()
        const y = b.children[index].textContent.trim()
        return asc ? x.localeCompare(y, 'fr', { numeric: true }) : y.localeCompare(x, 'fr', { numeric: true })
      })
      rows.forEach((row) => table.tBodies[0].appendChild(row))
    })
  })
</script>
Classe / attributDescription
.ui-tablebase, à poser sur <table>
.ui-table--stripelignes zébrées via :nth-child(even)
<thead>, <tbody>, <tr>, <td>structure écrite en dur, plus de .columns/.data
.ui-table__sort (sur un <button>) + aria-sort (sur le <th>)optionnel : rend le tri activable au clic et au clavier (Entrée/Espace, natif sur un <button>)

Tree

CSS + JS minimal

Arborescence en <ul> imbriquées. L'ouverture/fermeture des branches utilise <details>, zéro JS. Seule la cascade coché/indéterminé entre parents et enfants demande un petit script.

<div class="ui-tree" id="tree">
  <ul>
    <li>
      <details open>
        <summary>
          <label class="ui-checkbox"><input type="checkbox" /> Fruits</label>
        </summary>
        <ul>
          <li><label class="ui-checkbox"><input type="checkbox" /> Pomme</label></li>
          <li><label class="ui-checkbox"><input type="checkbox" /> Banane</label></li>
        </ul>
      </details>
    </li>
    <li>
      <details>
        <summary>
          <label class="ui-checkbox"><input type="checkbox" /> Légumes</label>
        </summary>
        <ul>
          <li><label class="ui-checkbox"><input type="checkbox" /> Carotte</label></li>
        </ul>
      </details>
    </li>
  </ul>
</div>

<style>
.ui-tree ul { list-style: none; margin: 0; padding-inline-start: var(--ui-spacing-lg); }
.ui-tree > ul { padding-inline-start: 0; }
.ui-tree li { margin: var(--ui-spacing-xs) 0; }
.ui-tree summary { display: flex; align-items: center; gap: var(--ui-spacing-xs); cursor: pointer; list-style: none; }
.ui-tree summary::-webkit-details-marker { display: none; }
.ui-tree summary::before { content: '▸'; display: inline-block; width: 1em; color: var(--ui-color-text-secondary); transition: transform var(--ui-transition-fast); }
.ui-tree details[open] > summary::before { transform: rotate(90deg); }
</style>

<script>
  (function () {
    const root = document.getElementById('tree')
    root.querySelectorAll('summary .ui-checkbox').forEach((label) => {
      label.addEventListener('click', (e) => e.stopPropagation())
    })
    root.querySelectorAll('input[type="checkbox"]').forEach((box) => {
      box.addEventListener('change', () => {
        const details = box.closest('details')
        if (box.closest('summary')) {
          details.querySelectorAll(':scope > ul input[type="checkbox"]').forEach((child) => {
            child.checked = box.checked
            child.indeterminate = false
          })
        }
        let parentDetails = box.closest('ul')?.closest('details')
        while (parentDetails) {
          const children = Array.from(parentDetails.querySelectorAll(':scope > ul input[type="checkbox"]'))
          const summaryBox = parentDetails.querySelector(':scope > summary input[type="checkbox"]')
          const checkedCount = children.filter((c) => c.checked).length
          summaryBox.checked = checkedCount === children.length
          summaryBox.indeterminate = checkedCount > 0 && checkedCount < children.length
          parentDetails = parentDetails.parentElement.closest('details')
        }
      })
    })
  })()
</script>
Classe / attributDescription
.ui-treeconteneur racine
<details> / <summary>expansion/repli natif, open pour une branche dépliée par défaut
.ui-checkboxcase à cocher (voir la section Checkbox)
cascade JSsynchronise coché/décoché parent ↔ enfants et l'état .indeterminate des ancêtres au change

Cascader

JS requis

Trois <select> natifs chaînés (pays / région / ville). Changer un niveau repeuple le suivant via JS ; les données sont un simple objet imbriqué, plus de composant custom.

<div class="ui-cascader">
  <select class="ui-cascader__select" id="cascader-country" aria-label="Pays"></select>
  <select class="ui-cascader__select" id="cascader-region" aria-label="Région"></select>
  <select class="ui-cascader__select" id="cascader-city" aria-label="Ville"></select>
</div>

<style>
.ui-cascader { display: flex; gap: var(--ui-spacing-sm); }
.ui-cascader__select {
  height: 36px;
  padding: 0 12px;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  font: inherit;
  font-size: var(--ui-font-size-md);
  cursor: pointer;
}
.ui-cascader__select:focus-visible { outline: none; box-shadow: var(--ui-focus-ring); }
</style>

<script>
  const data = {
    fr: { label: 'France', regions: {
      idf: { label: 'Île-de-France', cities: ['Paris', 'Versailles'] },
      ara: { label: 'Auvergne-Rhône-Alpes', cities: ['Lyon', 'Grenoble'] }
    } },
    be: { label: 'Belgique', regions: {
      brux: { label: 'Bruxelles-Capitale', cities: ['Bruxelles'] },
      wal: { label: 'Wallonie', cities: ['Liège', 'Namur'] }
    } }
  }
  const countrySelect = document.getElementById('cascader-country')
  const regionSelect = document.getElementById('cascader-region')
  const citySelect = document.getElementById('cascader-city')
  const fill = (select, entries) => {
    select.innerHTML = entries.map(([value, label]) => `<option value="${value}">${label}</option>`).join('')
  }
  const updateCities = () => {
    const region = data[countrySelect.value].regions[regionSelect.value]
    fill(citySelect, region.cities.map((city) => [city, city]))
  }
  const updateRegions = () => {
    const regions = data[countrySelect.value].regions
    fill(regionSelect, Object.entries(regions).map(([value, r]) => [value, r.label]))
    updateCities()
  }
  fill(countrySelect, Object.entries(data).map(([value, c]) => [value, c.label]))
  countrySelect.addEventListener('change', updateRegions)
  regionSelect.addEventListener('change', updateCities)
  updateRegions()
</script>
Classe / attributDescription
.ui-cascaderconteneur flex des trois listes
.ui-cascader__selectstyle appliqué à chaque <select> natif
objet de données JS imbriquéremplace .options ; une clé par pays/région avec ses enfants
event changesur chaque <select>, repeuple le niveau suivant

Transfer

JS requis

Deux <select multiple> natifs côte à côte. Les boutons du milieu déplacent réellement les <option> sélectionnées d'une liste à l'autre via appendChild.

<div class="ui-transfer">
  <select multiple class="ui-transfer__list" id="transfer-left" size="6" aria-label="Utilisateurs disponibles">
    <option value="u1">Alice</option>
    <option value="u2">Bruno</option>
    <option value="u3" disabled>Chloé</option>
  </select>
  <div class="ui-transfer__actions">
    <button class="ui-btn ui-btn--sm" id="transfer-right" aria-label="Ajouter à la sélection">→</button>
    <button class="ui-btn ui-btn--sm" id="transfer-back" aria-label="Retirer de la sélection">←</button>
  </div>
  <select multiple class="ui-transfer__list" id="transfer-right-list" size="6" aria-label="Utilisateurs sélectionnés">
    <option value="u4">Diane</option>
  </select>
</div>

<style>
.ui-transfer { display: flex; align-items: center; gap: var(--ui-spacing-md); }
.ui-transfer__list {
  width: 200px;
  padding: var(--ui-spacing-xs);
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  font: inherit;
  font-size: var(--ui-font-size-md);
}
.ui-transfer__list option { padding: var(--ui-spacing-xs) var(--ui-spacing-sm); border-radius: var(--ui-radius-sm); }
.ui-transfer__list option:checked { background: var(--ui-color-primary-soft); color: var(--ui-color-primary); }
.ui-transfer__list option:disabled { color: var(--ui-color-disabled-text); }
.ui-transfer__actions { display: flex; flex-direction: column; gap: var(--ui-spacing-sm); }
</style>

<script>
  const left = document.getElementById('transfer-left')
  const right = document.getElementById('transfer-right-list')
  const move = (from, to) => {
    Array.from(from.selectedOptions).forEach((option) => {
      if (!option.disabled) to.appendChild(option)
    })
  }
  document.getElementById('transfer-right').addEventListener('click', () => move(left, right))
  document.getElementById('transfer-back').addEventListener('click', () => move(right, left))
</script>
Classe / attributDescription
.ui-transferconteneur flex des deux listes et des boutons
.ui-transfer__listà poser sur chaque <select multiple>
.ui-transfer__actionscolonne des boutons →/←
disabled sur <option>exclut l'option du transfert

Popconfirm

CSS + JS minimal

Confirmation contextuelle basée sur l'API native popover : l'ouverture et l'annulation se font sans JS via popovertarget. Seul le bouton "Supprimer" a besoin de 2-3 lignes pour exécuter l'action puis refermer le popover.

Supprimer cet élément ?

<button popovertarget="confirm-delete" class="ui-btn ui-btn--danger">Supprimer</button>
<div id="confirm-delete" popover class="ui-popconfirm">
  <p>Supprimer cet élément ?</p>
  <div class="ui-popconfirm__actions">
    <button popovertarget="confirm-delete" popovertargetaction="hide" class="ui-btn ui-btn--sm">Annuler</button>
    <button id="confirm-delete-yes" class="ui-btn ui-btn--danger ui-btn--sm">Supprimer</button>
  </div>
</div>

<style>
.ui-popconfirm {
  margin: auto;
  max-width: 260px;
  padding: var(--ui-spacing-lg);
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-lg);
  background: var(--ui-color-bg-elevated);
  box-shadow: var(--ui-shadow-lg);
}
.ui-popconfirm::backdrop { background: var(--ui-overlay-bg); }
.ui-popconfirm p { margin: 0 0 var(--ui-spacing-md); font-size: var(--ui-font-size-md); color: var(--ui-color-text); }
.ui-popconfirm__actions { display: flex; justify-content: flex-end; gap: var(--ui-spacing-sm); }
</style>

<script>
  document.getElementById('confirm-delete-yes').addEventListener('click', () => {
    console.log('confirmé')
    document.getElementById('confirm-delete').hidePopover()
  })
</script>
Classe / attributDescription
popovertargetouvre le popover natif au clic, sans JS
popovertargetaction="hide"ferme le popover natif, sans JS
.ui-popconfirm / .ui-popconfirm__actionshabillage visuel de l'élément popover
JS (2-3 lignes)exécute l'action confirmée puis appelle hidePopover()

Loading

JS requis

Fonction autonome showLoadingMask(target, { text }) qui superpose un overlay avec spinner CSS sur target (qui doit avoir position: relative) et retourne une fonction hide().

<div id="target" style="position:relative">
  <button class="ui-btn ui-btn--primary" id="trigger">Charger les données</button>
</div>

<style>
.ui-loading-mask {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--ui-spacing-sm);
  background: color-mix(in srgb, var(--ui-color-bg-elevated) 80%, transparent);
  z-index: 10;
}
.ui-loading-mask__spinner {
  width: 24px;
  height: 24px;
  border: 3px solid var(--ui-color-border);
  border-top-color: var(--ui-color-primary);
  border-radius: 50%;
  animation: ui-loading-spin 0.7s linear infinite;
}
.ui-loading-mask__text { font-size: var(--ui-font-size-sm); color: var(--ui-color-text-secondary); }
@keyframes ui-loading-spin { to { transform: rotate(360deg); } }
</style>

<script>
  function showLoadingMask(target, { text = 'Chargement...' } = {}) {
    const mask = document.createElement('div')
    mask.className = 'ui-loading-mask'
    mask.innerHTML = `<span class="ui-loading-mask__spinner"></span><span class="ui-loading-mask__text">${text}</span>`
    target.appendChild(mask)
    return function hide() {
      mask.remove()
    }
  }

  trigger.addEventListener('click', () => {
    const hide = showLoadingMask(target, { text: 'Chargement...' })
    setTimeout(hide, 2000)
  })
</script>
Classe / attributDescription
showLoadingMask(target, { text })fonction autonome, injecte l'overlay dans target et retourne hide()
targetdoit avoir position: relative pour que l'overlay se superpose correctement
.ui-loading-maskoverlay position: absolute avec spinner et texte

Result

CSS-first

Bloc de résultat après une action, en HTML/CSS pur : icône SVG, titre, texte et actions. Aucune interactivité requise.

Paiement effectué

Un e-mail de confirmation vient de t'être envoyé.

Échec du paiement

Vérifie les informations de ta carte et réessaie.

<div class="ui-result ui-result--success">
  <svg class="ui-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
    <circle cx="12" cy="12" r="10"/>
    <path d="M8 12.5l2.5 2.5L16 9"/>
  </svg>
  <h3>Paiement effectué</h3>
  <p>Un e-mail de confirmation vient de t'être envoyé.</p>
  <div class="ui-result__actions">
    <button class="ui-btn ui-btn--primary">Retour à l'accueil</button>
  </div>
</div>

<style>
.ui-result { display: flex; flex-direction: column; align-items: center; text-align: center; gap: var(--ui-spacing-sm); padding: var(--ui-spacing-xl); }
.ui-result .ui-icon { width: 56px; height: 56px; }
.ui-result h3 { margin: 0; font-size: var(--ui-font-size-lg); color: var(--ui-color-text); }
.ui-result p { margin: 0; color: var(--ui-color-text-secondary); }
.ui-result__actions { display: flex; gap: var(--ui-spacing-sm); margin-top: var(--ui-spacing-sm); }
.ui-result--success .ui-icon { color: var(--ui-color-success); }
.ui-result--warning .ui-icon { color: var(--ui-color-warning); }
.ui-result--error .ui-icon { color: var(--ui-color-danger); }
.ui-result--info .ui-icon { color: var(--ui-color-info); }
</style>
Classe / attributDescription
.ui-resultconteneur centré
.ui-result--success / --warning / --error / --infocolore l'icône .ui-icon via currentColor
.ui-result__actionszone des boutons d'action
.ui-iconSVG à dimensionner/colorer librement

Drawer

CSS + JS minimal

Un <dialog> natif positionné en CSS pour glisser depuis le bord droit de l'écran. Ouverture/fermeture natives via showModal()/close(), seuls les deux boutons ont besoin de JS.

Filtrer les résultats

Contenu du panneau latéral.

<button class="ui-btn ui-btn--primary" id="open">Ouvrir le panneau</button>
<dialog class="ui-drawer" id="drawer">
  <h3>Filtrer les résultats</h3>
  <p>Contenu du panneau latéral.</p>
  <div class="ui-drawer__footer">
    <button class="ui-btn" id="close">Fermer</button>
  </div>
</dialog>

<style>
.ui-drawer {
  position: fixed;
  inset-inline-end: 0;
  top: 0;
  bottom: 0;
  left: auto;
  height: 100%;
  margin: 0;
  width: 360px;
  max-width: 90vw;
  padding: var(--ui-spacing-xl);
  border: none;
  border-inline-start: 1px solid var(--ui-color-border);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  box-shadow: var(--ui-shadow-lg);
  transform: translateX(100%);
  transition: transform var(--ui-transition), overlay var(--ui-transition) allow-discrete, display var(--ui-transition) allow-discrete;
}
.ui-drawer[open] { transform: translateX(0); }
@starting-style {
  .ui-drawer[open] { transform: translateX(100%); }
}
.ui-drawer::backdrop { background: var(--ui-overlay-bg); }
.ui-drawer h3 { margin: 0 0 var(--ui-spacing-sm); font-size: var(--ui-font-size-lg); }
.ui-drawer__footer { margin-top: var(--ui-spacing-lg); display: flex; justify-content: flex-end; }
</style>

<script>
  open.addEventListener('click', () => drawer.showModal())
  close.addEventListener('click', () => drawer.close())
</script>
Classe / attributDescription
.ui-drawerà poser sur <dialog>, positionne le panneau à droite de l'écran
showModal() / close()API native du <dialog>, ouvre/ferme le panneau
::backdropfond assombri natif derrière le panneau
.ui-drawer__footerzone d'actions en bas du panneau

Tooltip

CSS-first

Une bulle d'aide en CSS pur via l'attribut data-tooltip et un ::after affiché au :hover / :focus-visible. Le contenu généré en CSS (attr()) n'est jamais exposé aux lecteurs d'écran : un <span class="ui-sr-only"> caché visuellement, relié via aria-describedby, porte le même texte pour l'accessibilité.

Astuce en haut Astuce en bas Astuce à droite
<span class="ui-tooltip" data-tooltip="Astuce">
  <button class="ui-btn" aria-describedby="tooltip-1">Survole-moi</button>
  <span class="ui-sr-only" id="tooltip-1">Astuce</span>
</span>
<span class="ui-tooltip ui-tooltip--bottom" data-tooltip="Astuce en bas">
  <button class="ui-btn" aria-describedby="tooltip-2">Survole-moi</button>
  <span class="ui-sr-only" id="tooltip-2">Astuce en bas</span>
</span>

<style>
.ui-tooltip { position: relative; display: inline-flex; }
.ui-tooltip::after {
  content: attr(data-tooltip);
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
  padding: 4px 8px;
  border-radius: var(--ui-radius-sm);
  background: var(--ui-color-text);
  color: var(--ui-color-bg-elevated);
  font-size: var(--ui-font-size-sm);
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--ui-transition-fast);
  z-index: 10;
}
.ui-tooltip:hover::after,
.ui-tooltip:focus-within::after { opacity: 1; }

.ui-tooltip--bottom::after { top: calc(100% + 8px); bottom: auto; }
.ui-tooltip--right::after { top: 50%; left: calc(100% + 8px); bottom: auto; transform: translateY(-50%); }
</style>
Classe / attributDescription
.ui-tooltipconteneur à poser autour de l'élément déclencheur
data-tooltiptexte de la bulle visuelle, injecté via content: attr(data-tooltip)
.ui-tooltip--bottom / --rightchange le placement de la bulle (défaut : en haut)
.ui-sr-only + aria-describedbyrépète le même texte, caché visuellement, pour les lecteurs d'écran (le CSS généré seul ne suffit pas)

Tabs

CSS-first

Pattern radios + labels : chaque onglet est un <input type="radio"> caché, et le panneau associé est affiché via :checked ~ [data-panel]. Zéro JavaScript.

Contenu de l'onglet Profil.
Contenu de l'onglet Sécurité.
Contenu de l'onglet Facturation.
<div class="ui-tabs">
  <input type="radio" name="demo-tabs" id="tab-profil" class="ui-tabs__input" checked />
  <input type="radio" name="demo-tabs" id="tab-securite" class="ui-tabs__input" />
  <div class="ui-tabs__list" role="tablist">
    <label for="tab-profil" class="ui-tabs__tab">Profil</label>
    <label for="tab-securite" class="ui-tabs__tab">Sécurité</label>
  </div>
  <div class="ui-tabs__panel" data-panel="profil">Contenu profil…</div>
  <div class="ui-tabs__panel" data-panel="securite">Contenu sécurité…</div>
</div>

<style>
.ui-tabs { display: flex; flex-direction: column; }
.ui-tabs__input { position: absolute; opacity: 0; pointer-events: none; }
.ui-tabs__list { display: flex; gap: var(--ui-spacing-md); border-bottom: 1px solid var(--ui-color-border); }
.ui-tabs__tab {
  padding: var(--ui-spacing-sm) var(--ui-spacing-xs);
  margin-bottom: -1px;
  border-bottom: 2px solid transparent;
  color: var(--ui-color-text-secondary);
  font-size: var(--ui-font-size-md);
  cursor: pointer;
}
.ui-tabs__panel { display: none; padding: var(--ui-spacing-md) 0; color: var(--ui-color-text); }

#tab-profil:checked ~ .ui-tabs__list label[for="tab-profil"],
#tab-securite:checked ~ .ui-tabs__list label[for="tab-securite"],
#tab-facturation:checked ~ .ui-tabs__list label[for="tab-facturation"] {
  border-bottom-color: var(--ui-color-primary);
  color: var(--ui-color-primary);
}

#tab-profil:checked ~ [data-panel="profil"] { display: block; }
#tab-securite:checked ~ [data-panel="securite"] { display: block; }
#tab-facturation:checked ~ [data-panel="facturation"] { display: block; }
</style>
Classe / attributDescription
.ui-tabsconteneur ; les inputs sont ses enfants directs
.ui-tabs__inputradio caché, un par onglet, même name pour tous
.ui-tabs__list / .ui-tabs__tabbarre d'onglets et libellé cliquable (<label for="...">)
.ui-tabs__panel + data-panelpanneau de contenu, affiché via #tab-x:checked ~ [data-panel="x"]

Dialog

CSS + JS minimal

Le <dialog> natif : showModal() ouvre la fenêtre modale, close() la ferme. Escape et le fond (::backdrop) sont gérés nativement par le navigateur.

Confirmer l'action

Cette action est irréversible. Veux-tu continuer ?

<button type="button" id="open-dialog" class="ui-btn ui-btn--primary">Ouvrir la fenêtre</button>
<dialog id="demo-dialog" class="ui-dialog">
  <h3 class="ui-dialog__title">Confirmer l'action</h3>
  <p>Cette action est irréversible. Veux-tu continuer ?</p>
  <div class="ui-dialog__footer">
    <button type="button" id="cancel-dialog" class="ui-btn">Annuler</button>
    <button type="button" id="confirm-dialog" class="ui-btn ui-btn--danger">Confirmer</button>
  </div>
</dialog>

<style>
.ui-dialog {
  padding: var(--ui-spacing-xl);
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-lg);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  box-shadow: var(--ui-shadow-lg);
  max-width: 420px;
}
.ui-dialog::backdrop { background: var(--ui-overlay-bg); }
.ui-dialog__title { margin: 0 0 var(--ui-spacing-md); font-size: var(--ui-font-size-lg); }
.ui-dialog__footer { display: flex; justify-content: flex-end; gap: var(--ui-spacing-sm); margin-top: var(--ui-spacing-lg); }
</style>

<script>
  document.getElementById('open-dialog').addEventListener('click', () => {
    document.getElementById('demo-dialog').showModal()
  })
  document.getElementById('cancel-dialog').addEventListener('click', () => {
    document.getElementById('demo-dialog').close()
  })
</script>
Classe / attributDescription
.ui-dialogà poser sur un <dialog> natif
.showModal()méthode native qui ouvre la fenêtre en mode modal avec le fond
.close()méthode native qui ferme la fenêtre
::backdroppseudo-élément natif du fond, stylé via .ui-dialog::backdrop
.ui-dialog__title / __footertitre et zone d'actions du contenu

Kbd

CSS-first

Affiche des raccourcis clavier comme des touches physiques grâce à l'élément sémantique natif <kbd> — présent dans la spec HTML depuis longtemps, mais absent de la quasi-totalité des UI kits.

Ctrl + K + Shift + P Escape
<span><kbd class="ui-kbd">Ctrl</kbd> + <kbd class="ui-kbd">K</kbd></span>
<span><kbd class="ui-kbd">⌘</kbd> + <kbd class="ui-kbd">Shift</kbd> + <kbd class="ui-kbd">P</kbd></span>
<kbd class="ui-kbd">Escape</kbd>

<style>
.ui-kbd {
  display: inline-flex;
  align-items: center;
  height: 22px;
  padding: 0 6px;
  border: 1px solid var(--ui-color-border);
  border-bottom-width: 3px;
  border-radius: var(--ui-radius-sm);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  font: 700 0.78rem 'SF Mono', Menlo, Consolas, monospace;
  box-shadow: 0 1px 0 var(--ui-color-border);
  white-space: nowrap;
  user-select: none;
}
</style>
Élément / classeDescription
<kbd>élément HTML natif pour représenter une saisie clavier (spec W3C)
.ui-kbdstyle de base : aspect touche physique avec bordure basse épaissie
+ entre deux kbdtexte nu pour séparer les touches — pas besoin de classe

OTP / PIN

CSS + JS minimal

Saisie d'un code à N chiffres (2FA, PIN, vérification) : des <input maxlength="1"> natifs côte à côte. Le JS ajoute l'avance automatique au caractère suivant et la gestion du collé — sans ça, les champs ne sont que du CSS.

<div class="ui-otp" id="otp" aria-label="Code de vérification">
  <input class="ui-otp__cell" type="tel" inputmode="numeric" maxlength="1" aria-label="Chiffre 1" />
  <input class="ui-otp__cell" type="tel" inputmode="numeric" maxlength="1" aria-label="Chiffre 2" />
  <input class="ui-otp__cell" type="tel" inputmode="numeric" maxlength="1" aria-label="Chiffre 3" />
  <input class="ui-otp__cell" type="tel" inputmode="numeric" maxlength="1" aria-label="Chiffre 4" />
  <input class="ui-otp__cell" type="tel" inputmode="numeric" maxlength="1" aria-label="Chiffre 5" />
  <input class="ui-otp__cell" type="tel" inputmode="numeric" maxlength="1" aria-label="Chiffre 6" />
</div>

<style>
.ui-otp {
  display: flex;
  gap: var(--ui-spacing-sm);
}
.ui-otp__cell {
  width: 44px;
  height: 52px;
  text-align: center;
  font-size: 1.4rem;
  font-weight: 700;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  caret-color: var(--ui-color-primary);
  transition: border-color var(--ui-transition-fast);
}
.ui-otp__cell:hover { border-color: var(--ui-color-border-hover); }
.ui-otp__cell:focus {
  outline: none;
  border-color: var(--ui-color-primary);
  box-shadow: var(--ui-focus-ring);
}
</style>

<script>
  const cells = Array.from(document.getElementById('otp').querySelectorAll('.ui-otp__cell'))
  cells.forEach((cell, i) => {
    cell.addEventListener('input', () => {
      cell.value = cell.value.replace(/\D/g, '').slice(-1)
      if (cell.value && cells[i + 1]) cells[i + 1].focus()
    })
    cell.addEventListener('keydown', (e) => {
      if (e.key === 'Backspace' && !cell.value && cells[i - 1]) { cells[i - 1].focus(); cells[i - 1].value = '' }
    })
    cell.addEventListener('paste', (e) => {
      e.preventDefault()
      const text = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, cells.length)
      cells.forEach((c, j) => { c.value = text[j] || '' })
      cells[Math.min(text.length, cells.length - 1)].focus()
    })
  })
</script>
Classe / attributDescription
.ui-otpconteneur flex des cellules
.ui-otp__cellchamp <input type="tel"> à une seule case
inputmode="numeric"clavier numérique sur mobile, sans les boutons +/- du type number
maxlength="1"limite native à un seul caractère par case
JS auto-avancefocus la case suivante à chaque saisie ; gère le collé (paste) en remplissant toutes les cases

Scroll Snap

CSS-first

Carrousel horizontal sans une seule ligne de JavaScript : scroll-snap-type force le navigateur à toujours s'arrêter sur un item entier. La navigation se fait aux gestes tactiles natifs, à la molette ou aux touches clavier — idéal comme alternative légère aux librairies de carousel.

Slide 1
Slide 2
Slide 3
Slide 4
<div class="ui-scroll-snap" tabindex="0" aria-label="Galerie">
  <div class="ui-scroll-snap__item"><div>Slide 1</div></div>
  <div class="ui-scroll-snap__item"><div>Slide 2</div></div>
  <div class="ui-scroll-snap__item"><div>Slide 3</div></div>
  <div class="ui-scroll-snap__item"><div>Slide 4</div></div>
</div>

<style>
.ui-scroll-snap {
  display: flex;
  gap: var(--ui-spacing-md);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  padding-bottom: 4px;
}
.ui-scroll-snap::-webkit-scrollbar { display: none; }
.ui-scroll-snap__item {
  flex: 0 0 100%;
  scroll-snap-align: start;
}
</style>
Classe / propriété CSSDescription
.ui-scroll-snapconteneur défilant, scroll-snap-type: x mandatory
.ui-scroll-snap__itemitem ancré via scroll-snap-align: start
flex: 0 0 100%chaque item occupe la pleine largeur ; ajuste à 80% pour un aperçu partiel du suivant
scroll-snap-type: x proximityaccrochage moins strict (snapping uniquement si proche d'un bord)
tabindex="0"permet la navigation au clavier (flèches) sans JavaScript

Meter

CSS-first

L'élément HTML <meter> représente une mesure dans une plage connue et sémantiquement stable (capacité, score, stock, niveau). Différent de <progress> qui dit « en cours » : <meter> dit « valeur actuelle dans une plage ». La plupart des UI kits l'ignorent complètement.

<label class="ui-label">Stockage utilisé (7 Go / 15 Go)
  <meter class="ui-meter" min="0" max="15" value="7" low="5" high="12" optimum="3">7 Go sur 15 Go</meter>
</label>

<label class="ui-label">Score (82 / 100)
  <meter class="ui-meter" min="0" max="100" value="82" low="40" high="75" optimum="100">82/100</meter>
</label>

<style>
.ui-meter {
  -webkit-appearance: none;
  appearance: none;
  display: block;
  width: 100%;
  height: 8px;
  border: none;
  border-radius: var(--ui-radius-full);
  background: var(--ui-color-bg-subtle);
  overflow: hidden;
}
.ui-meter::-webkit-meter-bar {
  background: var(--ui-color-bg-subtle);
  border: none;
  border-radius: var(--ui-radius-full);
}
.ui-meter::-webkit-meter-optimum-value { background: var(--ui-color-success); }
.ui-meter::-webkit-meter-suboptimum-value { background: var(--ui-color-warning); }
.ui-meter::-webkit-meter-even-less-good-value { background: var(--ui-color-danger); }
.ui-meter::-moz-meter-bar { background: var(--ui-color-success); border-radius: var(--ui-radius-full); }
</style>
AttributDescription
min, maxbornes de la plage (défaut : 0 et 1)
valuevaleur actuelle à afficher
low, highseuils qui définissent les zones « basse » et « haute »
optimumvaleur idéale — détermine si une zone basse/haute est suboptimale ou non (colore automatiquement en vert/orange/rouge)
contenu textefallback accessible pour les navigateurs sans support, et lu par les lecteurs d'écran

Counter

JS requis

Nombre qui s'anime vers sa valeur cible dès qu'il entre dans le viewport, avec IntersectionObserver. Utilise l'élément sémantique <output> (résultat d'un calcul ou d'une action, selon la spec HTML) avec aria-live="polite" pour les lecteurs d'écran.

0 €

Chiffre d'affaires

+0

Utilisateurs

0 %

Satisfaction

<output class="ui-counter" data-target="128430" data-prefix="" data-suffix=" €" aria-live="polite">0 €</output>
<output class="ui-counter" data-target="4820" data-prefix="+" data-suffix="" aria-live="polite">+0</output>
<output class="ui-counter" data-target="98.6" data-prefix="" data-suffix=" %" data-decimals="1" aria-live="polite">0 %</output>

<style>
.ui-counter {
  display: inline-block;
  font-size: 2rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--ui-color-primary);
  line-height: 1;
}
</style>

<script>
  function animateCounter(el) {
    const target = parseFloat(el.dataset.target) || 0
    const decimals = parseInt(el.dataset.decimals) || 0
    const prefix = el.dataset.prefix || ''
    const suffix = el.dataset.suffix || ''
    const duration = 1400
    let start = null

    function step(ts) {
      if (!start) start = ts
      const progress = Math.min((ts - start) / duration, 1)
      const eased = 1 - (1 - progress) ** 3
      el.textContent = prefix +
        (target * eased).toLocaleString('fr-FR', {
          minimumFractionDigits: decimals,
          maximumFractionDigits: decimals
        }) + suffix
      if (progress < 1) requestAnimationFrame(step)
    }
    requestAnimationFrame(step)
  }

  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) { animateCounter(entry.target); observer.unobserve(entry.target) }
    })
  }, { threshold: 0.5 })

  document.querySelectorAll('.ui-counter').forEach((el) => observer.observe(el))
</script>
Attribut / classeDescription
<output>élément HTML natif pour les résultats de calcul — sémantiquement juste pour une valeur dynamique
aria-live="polite"annonce la valeur finale aux lecteurs d'écran sans interrompre la lecture en cours
data-targetvaleur numérique cible de l'animation
data-prefix / data-suffixtexte avant/après le nombre (+, , %...)
data-decimalsnombre de décimales (défaut : 0)
IntersectionObserverdéclenche l'animation à l'entrée dans le viewport, pas au chargement de la page

Floating Label

CSS-first

Label qui descend en placeholder puis remonte quand le champ est rempli — en CSS pur grâce à :not(:placeholder-shown) et au sélecteur de fratrie ~, sans une ligne de JavaScript.

<div class="ui-floating-field">
  <input class="ui-floating-input" type="text" id="prenom" placeholder=" " />
  <label class="ui-floating-label" for="prenom">Prénom</label>
</div>

<style>
.ui-floating-field { position: relative; width: 100%; }
.ui-floating-input {
  display: block;
  width: 100%;
  height: 52px;
  padding: 20px 12px 6px;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text);
  font: inherit;
  font-size: var(--ui-font-size-md);
  transition: border-color var(--ui-transition-fast);
}
.ui-floating-input:hover { border-color: var(--ui-color-border-hover); }
.ui-floating-input:focus { outline: none; border-color: var(--ui-color-primary); box-shadow: var(--ui-focus-ring); }
.ui-floating-label {
  position: absolute;
  left: 12px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--ui-color-text-placeholder);
  font-size: var(--ui-font-size-md);
  pointer-events: none;
  transition: top var(--ui-transition-fast), font-size var(--ui-transition-fast), color var(--ui-transition-fast), transform var(--ui-transition-fast);
}
.ui-floating-input:not(:placeholder-shown) ~ .ui-floating-label,
.ui-floating-input:focus ~ .ui-floating-label {
  top: 12px;
  transform: translateY(0);
  font-size: 0.72rem;
  color: var(--ui-color-text-secondary);
}
.ui-floating-input:focus ~ .ui-floating-label { color: var(--ui-color-primary); }
</style>
Classe / astuce CSSDescription
placeholder=" "espace obligatoire pour que :placeholder-shown détecte le champ vide sans JS
:not(:placeholder-shown) ~ labelsélecteur CSS pur : champ rempli = label flottant
:focus ~ labelfratrie CSS : anime le label au focus même si le champ est encore vide

Spoiler / Details

CSS-first

Contenu révélable via l'élément HTML natif <details>/<summary> — le navigateur gère l'état ouvert/fermé nativement depuis 2014, sans JavaScript.

Qu'est-ce que le CSS-first ?
Le CSS-first consiste à exprimer le comportement visuel (accordéon, bascule...) uniquement avec du CSS, sans JavaScript, en s'appuyant sur les sélecteurs d'état natifs comme :checked, :has().
Peut-on animer l'ouverture ?
Avec interpolate-size: allow-keywords (CSS 2024) on peut transitionner height: 0 vers height: auto. En attendant, une transition sur opacity et padding est un bon compromis.
Ouvert par défaut (attribut open)
Ajoute l'attribut open sur <details> pour qu'il soit déplié au chargement.
<details class="ui-details">
  <summary class="ui-details__summary">Question ou titre</summary>
  <div class="ui-details__body">Réponse ou contenu révélé.</div>
</details>

<details class="ui-details" open>
  <summary class="ui-details__summary">Déplié par défaut</summary>
  <div class="ui-details__body">Visible dès le chargement.</div>
</details>

<style>
.ui-details { border: 1px solid var(--ui-color-border); border-radius: var(--ui-radius-md); overflow: hidden; }
.ui-details + .ui-details { margin-top: var(--ui-spacing-sm); }
.ui-details__summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ui-spacing-sm);
  padding: var(--ui-spacing-md) var(--ui-spacing-lg);
  font-weight: 500;
  cursor: pointer;
  list-style: none;
  user-select: none;
}
.ui-details__summary::-webkit-details-marker { display: none; }
.ui-details__summary::after { content: '+'; font-size: 1.2em; color: var(--ui-color-text-secondary); flex-shrink: 0; transition: transform var(--ui-transition-fast); }
.ui-details[open] .ui-details__summary::after { transform: rotate(45deg); }
.ui-details[open] { background: var(--ui-color-bg-subtle); }
.ui-details__body { padding: 0 var(--ui-spacing-lg) var(--ui-spacing-md); color: var(--ui-color-text-secondary); font-size: var(--ui-font-size-sm); }
</style>
Elément / classeDescription
<details>élément natif, gère l'état ouvert/fermé sans JavaScript
<summary>titre cliquable — le navigateur bascule open à chaque clic
openattribut HTML pour déplier par défaut
[open]sélecteur CSS ciblant l'état déplié

Popover API

CSS-first

Le nouvel attribut HTML popover crée un panneau non-modal avec popovertarget sur un bouton — ouverture, fermeture (Échap, clic extérieur) et accessibilité sans une ligne de JavaScript. Disponible dans tous les navigateurs modernes depuis 2024.

Le saviez-vous ?

L'attribut popover gère nativement le panneau flottant non-modal, l'accessibilité et la fermeture à l'échap — sans JavaScript.


<!-- Info légère -->
<button popovertarget="info-pop" class="ui-btn">Info</button>
<div id="info-pop" popover class="ui-popover-light">
  Contenu non-modal. Échap ou clic extérieur pour fermer.
</div>

<!-- Menu d'actions -->
<button popovertarget="menu-pop" class="ui-btn ui-btn--primary">Actions ▾</button>
<div id="menu-pop" popover class="ui-popover-menu">
  <button class="ui-popover-menu__item">Modifier</button>
  <button class="ui-popover-menu__item ui-popover-menu__item--danger">Supprimer</button>
</div>

<style>
.ui-popover-light {
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  box-shadow: var(--ui-shadow-lg);
  padding: var(--ui-spacing-md) var(--ui-spacing-lg);
  max-width: 320px;
}
.ui-popover-light::backdrop { background: transparent; }
.ui-popover-menu {
  padding: var(--ui-spacing-xs) 0;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  box-shadow: var(--ui-shadow-lg);
  min-width: 160px;
}
.ui-popover-menu::backdrop { background: transparent; }
.ui-popover-menu__item { display: flex; width: 100%; padding: var(--ui-spacing-sm) var(--ui-spacing-md); border: none; background: none; color: var(--ui-color-text); font: inherit; font-size: var(--ui-font-size-sm); cursor: pointer; }
.ui-popover-menu__item:hover { background: var(--ui-color-bg-subtle); }
.ui-popover-menu__item--danger { color: var(--ui-color-danger); }
.ui-popover-menu__sep { border: none; border-top: 1px solid var(--ui-color-border); margin: var(--ui-spacing-xs) 0; }
</style>
AttributDescription
popoverattribut HTML natif — masqué par défaut, accessible au clavier, fermé par Échap
popovertarget="id"sur un bouton : cible le popover à ouvrir/fermer — zéro JavaScript
::backdropfond derrière le popover — transparent pour non-modal, semi-opaque pour modal

Chip Filter

CSS-first

Filtres multi-sélection en « chips » sans JavaScript : des <input type="checkbox"> masqués dans un <label>, l'état actif pilé par :has(input:checked). Un type="radio" donne la sélection unique.

Technologies
<!-- Multi-sélection (checkbox) -->
<fieldset>
  <legend>Technologies</legend>
  <div class="ui-chip-group">
    <label class="ui-chip"><input type="checkbox" checked />JavaScript</label>
    <label class="ui-chip"><input type="checkbox" />CSS</label>
    <label class="ui-chip"><input type="checkbox" disabled />WebAssembly</label>
  </div>
</fieldset>

<!-- Sélection unique (radio) -->
<div class="ui-chip-group">
  <label class="ui-chip"><input type="radio" name="taille" checked />S</label>
  <label class="ui-chip"><input type="radio" name="taille" />M</label>
  <label class="ui-chip"><input type="radio" name="taille" />L</label>
</div>

<style>
.ui-chip-group { display: flex; flex-wrap: wrap; gap: var(--ui-spacing-sm); }
.ui-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 32px;
  padding: 0 var(--ui-spacing-md);
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-full);
  background: var(--ui-color-bg-elevated);
  color: var(--ui-color-text-secondary);
  font-size: var(--ui-font-size-sm);
  cursor: pointer;
  user-select: none;
  transition: background var(--ui-transition-fast), border-color var(--ui-transition-fast), color var(--ui-transition-fast);
}
.ui-chip input { position: absolute; opacity: 0; width: 0; height: 0; }
.ui-chip:has(input:checked) {
  background: var(--ui-color-primary-soft);
  border-color: var(--ui-color-primary);
  color: var(--ui-color-primary);
  font-weight: 500;
}
.ui-chip:has(input:disabled) { background: var(--ui-color-disabled-bg); border-color: var(--ui-color-disabled-border); color: var(--ui-color-disabled-text); cursor: not-allowed; }
</style>
Classe / astuce CSSDescription
.ui-chiplabel conteneur ; <input> masqué à l'intérieur
:has(input:checked)sélecteur CSS natif pour styler le chip actif sans JS
type="radio" + même namevariante sélection exclusive (un seul chip à la fois)

Color Swatch

CSS-first

Palette de couleurs ou de tailles prédéfinies via des radios masqués ; la couleur passe par une variable CSS inline --swatch-color, le halo de sélection par :has(input:checked).

Couleur

Taille

<!-- Pastilles couleur -->
<label class="ui-swatch" style="--swatch-color:#3f7ce0" title="Bleu">
  <input type="radio" name="couleur" value="blue" aria-label="Bleu" checked />
</label>
<label class="ui-swatch" style="--swatch-color:#2fa66a" title="Vert">
  <input type="radio" name="couleur" value="green" aria-label="Vert" />
</label>

<!-- Variante texte (taille) -->
<label class="ui-swatch-text"><input type="radio" name="taille" value="m" checked /><span>M</span></label>
<label class="ui-swatch-text"><input type="radio" name="taille" value="l" /><span>L</span></label>

<style>
.ui-swatch {
  display: inline-block;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--swatch-color);
  cursor: pointer;
  position: relative;
  transition: box-shadow var(--ui-transition-fast);
}
.ui-swatch input { position: absolute; opacity: 0; width: 0; height: 0; }
.ui-swatch:has(input:checked) {
  box-shadow: 0 0 0 2px var(--ui-color-bg-elevated), 0 0 0 4px var(--swatch-color);
}
.ui-swatch-text { display: inline-flex; cursor: pointer; }
.ui-swatch-text input { position: absolute; opacity: 0; width: 0; height: 0; }
.ui-swatch-text span {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-sm);
  font-size: var(--ui-font-size-sm);
  font-weight: 500;
  color: var(--ui-color-text-secondary);
  transition: all var(--ui-transition-fast);
}
.ui-swatch-text:has(input:checked) span {
  border-color: var(--ui-color-primary);
  color: var(--ui-color-primary);
  background: var(--ui-color-primary-soft);
}
.ui-swatch-text:has(input:disabled) span { background: var(--ui-color-disabled-bg); color: var(--ui-color-disabled-text); cursor: not-allowed; text-decoration: line-through; }
</style>
Classe / astuceDescription
--swatch-colorvariable CSS inline pour passer la couleur sans classe supplémentaire
:has(input:checked)halo de sélection CSS pur — pas de JS
.ui-swatch-textvariante textuelle pour les tailles ou options courtes

Bottom Sheet

CSS + JS minimal

Panneau qui glisse depuis le bas de l'écran, très courant sur mobile. Repose sur un <dialog> natif positionné en bas avec inset: auto 0 0 0. L'animation d'entrée utilise @starting-style, la propriété CSS 2024 qui évite le JS pour animer l'insertion dans le DOM.

Options de partage

Choisissez un canal de partage.

<button id="open-sheet" class="ui-btn ui-btn--primary">Ouvrir</button>
<dialog id="my-sheet" class="ui-bottom-sheet">
  <div class="ui-bottom-sheet__handle"></div>
  <div class="ui-bottom-sheet__body">
    Contenu du Bottom Sheet
    <button class="ui-btn" onclick="document.getElementById('my-sheet').close()">Fermer</button>
  </div>
</dialog>

<style>
.ui-bottom-sheet {
  position: fixed;
  inset: auto 0 0 0;
  margin: 0;
  width: 100%;
  max-height: 90dvh;
  border: none;
  border-radius: var(--ui-radius-xl) var(--ui-radius-xl) 0 0;
  background: var(--ui-color-bg-elevated);
  padding: 0;
  transition: transform 0.35s cubic-bezier(.32,.72,0,1), overlay 0.35s allow-discrete, display 0.35s allow-discrete;
}
.ui-bottom-sheet::backdrop { background: var(--ui-overlay-bg); transition: overlay 0.35s allow-discrete, display 0.35s allow-discrete, background 0.35s; }
@starting-style {
  .ui-bottom-sheet[open] { transform: translateY(100%); }
  .ui-bottom-sheet[open]::backdrop { background: transparent; }
}
.ui-bottom-sheet__handle { width: 40px; height: 4px; background: var(--ui-color-border); border-radius: var(--ui-radius-full); margin: 12px auto 0; }
.ui-bottom-sheet__body { padding: var(--ui-spacing-lg) var(--ui-spacing-lg) calc(var(--ui-spacing-lg) + env(safe-area-inset-bottom, 0px)); }
</style>

<script>
  document.getElementById('open-sheet').addEventListener('click', () => {
    document.getElementById('my-sheet').showModal()
  })
  document.getElementById('my-sheet').addEventListener('click', (e) => {
    if (e.target === e.currentTarget) e.currentTarget.close()
  })
</script>
CSS / attributDescription
inset: auto 0 0 0ancre le dialog en bas de l'écran (remplace le centrage par défaut de <dialog>)
@starting-styleCSS 2024 : état initial avant la transition d'entrée — supprime le JS pour l'animation d'ouverture
allow-discretepermet de transitionner display et overlay dans la même transition
env(safe-area-inset-bottom)gère l'encoche iPhone nativement

Sortable List

JS requis

Liste réordonnable par glisser-déposer avec l'API HTML5 native (draggable, événements dragstart / dragover / dragend) — aucune librairie externe.

  • ⚘  Design
  • 💻  Développement
  • 🚀  Déploiement
  • 📊  Analytics
  • 🔒  Sécurité
<ul class="ui-sortable" id="my-list">
  <li class="ui-sortable__item" draggable="true">Item A</li>
  <li class="ui-sortable__item" draggable="true">Item B</li>
  <li class="ui-sortable__item" draggable="true">Item C</li>
</ul>

<style>
.ui-sortable { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--ui-spacing-xs); }
.ui-sortable__item {
  display: flex;
  align-items: center;
  height: 44px;
  padding: 0 var(--ui-spacing-md);
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-elevated);
  cursor: grab;
  user-select: none;
  transition: opacity var(--ui-transition-fast), box-shadow var(--ui-transition-fast);
}
.ui-sortable__item:active { cursor: grabbing; }
.ui-sortable__item.is-dragging { opacity: 0.4; }
.ui-sortable__item.is-over { box-shadow: 0 -2px 0 0 var(--ui-color-primary); }
</style>

<script>
  const list = document.getElementById('my-list')
  let dragging = null
  list.addEventListener('dragstart', (e) => {
    dragging = e.target.closest('.ui-sortable__item')
    e.dataTransfer.effectAllowed = 'move'
    setTimeout(() => dragging?.classList.add('is-dragging'), 0)
  })
  list.addEventListener('dragend', () => {
    dragging?.classList.remove('is-dragging')
    list.querySelectorAll('.ui-sortable__item').forEach(el => el.classList.remove('is-over'))
    dragging = null
  })
  list.addEventListener('dragover', (e) => {
    e.preventDefault()
    const target = e.target.closest('.ui-sortable__item')
    if (!target || target === dragging) return
    list.querySelectorAll('.ui-sortable__item').forEach(el => el.classList.remove('is-over'))
    const after = e.clientY > target.getBoundingClientRect().top + target.offsetHeight / 2
    list.insertBefore(dragging, after ? target.nextSibling : target)
  })
</script>
Attribut / classeDescription
draggable="true"attribut HTML natif qui active le drag sur un élément
.is-draggingclasse CSS sur l'élément en cours de déplacement (opacité réduite)
dragstart / dragover / dragendévénements natifs HTML5 drag-and-drop

Relative Time

CSS + JS minimal

Affiche une date comme « il y a 3 heures » grâce à l'élément sémantique <time datetime=""> et Intl.RelativeTimeFormat natif — sans librairie de dates, le texte brut sert de fallback accessible.

<time class="ui-relative-time" datetime="2026-07-04T08:00:00">4 juillet 2026</time>
<time class="ui-relative-time" datetime="2025-01-15T00:00:00">15 janvier 2025</time>

<style>
.ui-relative-time {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  border-radius: var(--ui-radius-full);
  background: var(--ui-color-bg-subtle);
  color: var(--ui-color-text-secondary);
  font-size: var(--ui-font-size-sm);
}
</style>

<script>
  const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
  const thresholds = [
    [60, 'seconds', 1], [3600, 'minutes', 60], [86400, 'hours', 3600],
    [2592000, 'days', 86400], [31536000, 'months', 2592000], [Infinity, 'years', 31536000]
  ]
  document.querySelectorAll('.ui-relative-time').forEach((el) => {
    const diff = (new Date(el.getAttribute('datetime')) - Date.now()) / 1000
    const [, unit, div] = thresholds.find(([limit]) => Math.abs(diff) < limit)
    el.textContent = rtf.format(Math.round(diff / div), unit)
  })
</script>
Elément / APIDescription
<time datetime="">élément HTML sémantique pour les dates ; le contenu texte est le fallback lisible
Intl.RelativeTimeFormatAPI JavaScript native, sans librairie, pour les durées relatives localisées
{ numeric: 'auto' }produit « hier », « avant-hier » plutôt que « il y a -1 jour »

Copy

CSS + JS minimal

Bouton « Copier dans le presse-papier » avec retour visuel immédiat — via navigator.clipboard.writeText(), l'API web moderne et sécurisée.

npm install thomias-ui
python3 -m http.server 4173
<div class="ui-copy-group">
  <code class="ui-copy-text">npm install mon-paquet</code>
  <button type="button" class="ui-btn ui-btn--sm" data-copy-btn>Copier</button>
</div>

<style>
.ui-copy-group {
  display: flex;
  align-items: center;
  gap: var(--ui-spacing-sm);
  padding: var(--ui-spacing-sm) var(--ui-spacing-md);
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-md);
  background: var(--ui-color-bg-subtle);
  min-width: 0;
}
.ui-copy-text {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: 'SF Mono', Menlo, Consolas, monospace;
  font-size: var(--ui-font-size-sm);
  color: var(--ui-color-text);
  user-select: all;
}
.ui-copy-btn.is-copied { background: var(--ui-color-success-soft); border-color: var(--ui-color-success); color: var(--ui-color-success); }
</style>

<script>
  document.querySelectorAll('[data-copy-btn]').forEach((btn) => {
    btn.addEventListener('click', async () => {
      const text = btn.closest('.ui-copy-group').querySelector('.ui-copy-text').textContent.trim()
      await navigator.clipboard.writeText(text)
      const orig = btn.textContent
      btn.textContent = 'Copié !'
      btn.classList.add('is-copied')
      setTimeout(() => { btn.textContent = orig; btn.classList.remove('is-copied') }, 1800)
    })
  })
</script>
Classe / APIDescription
.ui-copy-groupconteneur flex avec le texte à gauche et le bouton à droite
user-select: allle texte se sélectionne entièrement d'un clic, même sans le bouton
navigator.clipboard.writeText()API moderne, asynchrone et sécurisée (HTTPS ou localhost)
.is-copiedclasse CSS pour le retour visuel « Copié ! »

Scroll Progress

CSS-first

Barre de progression de lecture en haut de page, 100% CSS — aucun JavaScript. Utilise animation-timeline: scroll(root) qui lie une animation CSS au défilement du document. Support Chrome 115+, Firefox 110+, Safari 18+.

Défile ce panneau : la barre bleue en haut progresse — sans JavaScript.

<!-- Pose cet élément au début du <body> -->
<div class="ui-scroll-progress" role="progressbar" aria-label="Progression de lecture"></div>

<style>
.ui-scroll-progress {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  background: var(--ui-color-primary);
  transform-origin: left center;
  transform: scaleX(0);
  z-index: 999;
  animation: ui-scroll-progress-anim linear both;
  animation-timeline: scroll(root);
}
@keyframes ui-scroll-progress-anim {
  to { transform: scaleX(1); }
}
</style>
Propriété CSSDescription
animation-timeline: scroll(root)lie l'animation au défilement de <html> — pas de JavaScript
animation-timeline: scroll(nearest)lie au plus proche conteneur défilant — utile pour un panneau interne
transform-origin: left centerfait grandir la barre de gauche à droite via scaleX(0 → 1)
@supports (animation-timeline: scroll())à utiliser pour appliquer ce composant uniquement quand le navigateur le supporte

Fieldset

CSS-first

L'élément HTML <fieldset> regroupe sémantiquement des champs de formulaire ; <legend> l'intitule. La plupart des UI kits remplacent ces natifs par des <div> anonymes, perdant ainsi l'accessibilité gratuite.

Préférences de contact
<fieldset class="ui-fieldset">
  <legend class="ui-fieldset__legend">Préférences de contact</legend>
  <label class="ui-label" for="email">Adresse e-mail</label>
  <input class="ui-input" type="email" id="email" />
  <label class="ui-radio"><input type="radio" name="contact" value="email" checked /> Par e-mail</label>
  <label class="ui-radio"><input type="radio" name="contact" value="phone" /> Par téléphone</label>
</fieldset>

<style>
.ui-fieldset {
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-lg);
  padding: var(--ui-spacing-lg);
  margin: 0;
}
.ui-fieldset__legend {
  padding: 0 var(--ui-spacing-sm);
  font-size: var(--ui-font-size-sm);
  font-weight: 600;
  color: var(--ui-color-text-secondary);
}
</style>
Elément / classeDescription
<fieldset>groupe sémantique natif ; les lecteurs d'écran annoncent le groupe à la première interaction
<legend>intitulé accessible du groupe, flottant dans la bordure
disabledattribut natif sur <fieldset> qui désactive tous les champs enfants d'un coup

Sticky Table

CSS-first

Un <table> dans un conteneur overflow: auto avec position: sticky sur le <thead> — le navigateur gère l'en-tête fixe au défilement horizontal et vertical, sans JavaScript.

NomRôleDépartementÂgeVille
Alice DupontDesignerProduit32Paris
Bruno MartinDéveloppeurIngénierie28Lyon
Chloé BernardChef de projetPMO41Bordeaux
Diane SimonAnalysteData36Nantes
Étienne PetitDevOpsInfra30Marseille
Farid RezaQA EngineerQualité27Toulouse
<div style="max-height:200px;overflow:auto;border:1px solid var(--ui-color-border);border-radius:var(--ui-radius-md)">
  <table class="ui-sticky-table">
    <thead>
      <tr><th>Nom</th><th>Rôle</th><th>Département</th></tr>
    </thead>
    <tbody>
      <tr><td>Alice</td><td>Designer</td><td>Produit</td></tr>
      <!-- ... -->
    </tbody>
  </table>
</div>

<style>
.ui-sticky-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--ui-font-size-sm);
}
.ui-sticky-table th, .ui-sticky-table td {
  padding: var(--ui-spacing-sm) var(--ui-spacing-md);
  text-align: left;
  border-bottom: 1px solid var(--ui-color-border);
  white-space: nowrap;
}
.ui-sticky-table thead th {
  position: sticky;
  top: 0;
  background: var(--ui-color-bg-elevated);
  font-weight: 600;
  z-index: 1;
  box-shadow: 0 1px 0 var(--ui-color-border);
}
.ui-sticky-table tbody tr:hover td { background: var(--ui-color-bg-subtle); }
</style>
CSSDescription
position: sticky; top: 0colle le <thead> en haut du conteneur défilant
overflow: autosur le conteneur : défilement activé (requis pour que sticky fonctionne)
z-index: 1garantit que l'en-tête passe au-dessus des cellules lors du défilement

Masonry

CSS-first

Disposition en cascade (style Pinterest) via la propriété CSS columns — zéro JavaScript, zéro librairie. Les items se répartissent automatiquement dans les colonnes de haut en bas.

Item court
Item plus haut
Item moyen
Item court
Item très haut
<div class="ui-masonry">
  <div class="ui-masonry__item">Contenu 1</div>
  <div class="ui-masonry__item">Contenu plus long...</div>
  <div class="ui-masonry__item">Contenu 3</div>
</div>

<style>
.ui-masonry {
  columns: 3;
  column-gap: var(--ui-spacing-md);
}
.ui-masonry__item {
  break-inside: avoid;
  margin-bottom: var(--ui-spacing-md);
}
@media (max-width: 600px) {
  .ui-masonry { columns: 2; }
}
@media (max-width: 360px) {
  .ui-masonry { columns: 1; }
}
</style>
CSSDescription
columns: 3nombre de colonnes (ou largeur minimale d'une colonne : columns: 280px)
break-inside: avoidempêche un item d'être coupé entre deux colonnes
margin-bottomécartement vertical entre items (gap ne s'applique pas aux colonnes CSS)

Container Query

CSS-first

La carte répond à sa propre largeur plutôt qu'à celle du viewport via @container — la nouveauté CSS 2023 qui remplace les media queries pour les composants réutilisables. Zéro JavaScript.

La mise en page change selon la largeur de la carte

Au-dessus de 400 px, l'image passe à gauche et le contenu à droite — automatiquement, sans media query ni JavaScript.

Format compact

Cette carte fait moins de 400 px, donc elle empile verticalement.

<div class="ui-cq-card">
  <img class="ui-cq-card__img" src="..." alt="" />
  <div class="ui-cq-card__body">
    <p class="ui-cq-card__title">Titre</p>
    <p class="ui-cq-card__desc">Description</p>
  </div>
</div>

<style>
.ui-cq-card {
  container-type: inline-size;
  border: 1px solid var(--ui-color-border);
  border-radius: var(--ui-radius-lg);
  overflow: hidden;
}
.ui-cq-card__img {
  display: block;
  width: 100%;
  height: 120px;
  object-fit: cover;
}
.ui-cq-card__body {
  padding: var(--ui-spacing-md);
}
.ui-cq-card__title { margin: 0 0 4px; font-weight: 600; }
.ui-cq-card__desc { margin: 0; color: var(--ui-color-text-secondary); font-size: var(--ui-font-size-sm); }

@container (min-width: 400px) {
  .ui-cq-card {
    display: flex;
    align-items: center;
  }
  .ui-cq-card__img {
    width: 140px;
    height: 100%;
    flex-shrink: 0;
  }
}
</style>
CSSDescription
container-type: inline-sizedéclare le conteneur de référence pour les enfants
@container (min-width: 400px)règle appliquée quand le conteneur fait au moins 400 px (pas le viewport)
container-nameoptionnel : nommer un conteneur pour cibler précisément avec @container my-name (...)

Abbr Tooltip

CSS-first

Tooltip 100% CSS depuis l'attribut title natif de <abbr>::after { content: attr(title); } in2ecte le texte du tooltip sans JavaScript. La bulle apparaît au survol uniquement via :hover.

La technologie CSS et le DOM sont les deux piliers du Web.

<p>
  La technologie
  <abbr class="ui-abbr" title="Cascading Style Sheets">CSS</abbr>
  et le
  <abbr class="ui-abbr" title="Document Object Model">DOM</abbr>
  sont les piliers du Web.
</p>

<style>
.ui-abbr {
  position: relative;
  text-decoration: underline dotted var(--ui-color-text-secondary);
  cursor: help;
}
.ui-abbr::after {
  content: attr(title);
  position: absolute;
  bottom: calc(100% + 6px);
  left: 50%;
  transform: translateX(-50%);
  padding: 4px 8px;
  border-radius: var(--ui-radius-sm);
  background: var(--ui-color-text);
  color: var(--ui-color-bg);
  font-size: 0.78rem;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--ui-transition-fast);
  z-index: 20;
}
.ui-abbr:hover::after { opacity: 1; }
</style>
CSSDescription
content: attr(title)lit l'attribut HTML natif title pour remplir le pseudo-élément — zéro JavaScript, zéro data-attribute
<abbr>élément HTML sémantique pour les abréviations ; accessible nativement
cursor: helppoint d'interrogation natif du navigateur pour signaler un texte définissable
construite uniquement avec des <details>/<summary> imbriqués — l'expansion est gérée par le navigateur, les traits de connexion par CSS.

<nav class="ui-tree" aria-label="Structure du projet">
  <details class="ui-tree__node" open>
    <summary class="ui-tree__item ui-tree__item--folder">📁 src</summary>
    <details class="ui-tree__node">
      <summary class="ui-tree__item ui-tree__item--folder">📁 components</summary>
      <div class="ui-tree__node">
        <span class="ui-tree__item ui-tree__item--file">📄 button.html</span>
      </div>
    </details>
  </details>
</nav>

<style>
.ui-tree { font-size: var(--ui-font-size-sm); }
.ui-tree__node { padding-left: 20px; }
.ui-tree summary { list-style: none; }
.ui-tree summary::-webkit-details-marker { display: none; }
.ui-tree__item {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 28px;
  padding: 0 var(--ui-spacing-xs);
  border-radius: var(--ui-radius-sm);
  cursor: default;
  user-select: none;
}
.ui-tree__item--folder { cursor: pointer; font-weight: 500; }
.ui-tree__item:hover { background: var(--ui-color-bg-subtle); }
</style>
ElémentDescription
<details open>nœud développé au chargement
<summary>titre cliquable du nœud ; le reste de <details> est le contenu enfant
imbricationplacer un <details> dans un autre <details> crée autant de niveaux qu'on veut

Range + Output

CSS + JS minimal

Un <input type="range"> lié à un <output> via l'attribut for : la valeur en direct s'affiche sans librairie, avec un seul attribut oninput inline. L'élément <output> est sémantique (résultat d'un calcul) et annoncé par les lecteurs d'écran.

40
16
<label class="ui-label" for="volume">Volume</label>
<output class="ui-range-output" for="volume" id="vol-out">40</output>
<input class="ui-slider" type="range" id="volume" min="0" max="100" value="40"
       oninput="document.getElementById('vol-out').value = this.value" />

<style>
.ui-range-output {
  font-size: var(--ui-font-size-sm);
  font-weight: 600;
  color: var(--ui-color-primary);
  min-width: 32px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
</style>
Elément / attributDescription
<output for="id">élément HTML sémantique pour les résultats, lié au champ via for
oninput="..."attribut inline : une seule expression, zéro import
aria-live<output> a implicitement aria-live="polite" — la valeur est annoncée

Confirm Dialog

CSS + JS minimal

Remplacement accessible de window.confirm() par un <dialog> stylisable, avec une promesse qui résout true ou false.

Cette action est irréversible. Confirmer la suppression ?

<button class="ui-btn ui-btn--danger" id="delete-btn">Supprimer</button>
<dialog id="confirm-dlg" class="ui-confirm-dialog">
  <p class="ui-confirm-dialog__message">Supprimer cet élément ?</p>
  <div class="ui-confirm-dialog__actions">
    <button type="button" class="ui-btn" value="cancel">Annuler</button>
    <button type="button" class="ui-btn ui-btn--danger" value="confirm">Supprimer</button>
  </div>
</dialog>

<style>
.ui-confirm-dialog {
  border: none;
  border-radius: var(--ui-radius-lg);
  background: var(--ui-color-bg-elevated);
  box-shadow: var(--ui-shadow-lg);
  padding: var(--ui-spacing-xl);
  max-width: 380px;
  color: var(--ui-color-text);
}
.ui-confirm-dialog::backdrop { background: var(--ui-overlay-bg); }
.ui-confirm-dialog__message { margin: 0 0 var(--ui-spacing-lg); }
.ui-confirm-dialog__actions { display: flex; justify-content: flex-end; gap: var(--ui-spacing-sm); }
</style>

<script>
  function uiConfirm(dialog) {
    return new Promise((resolve) => {
      dialog.showModal()
      const handle = (e) => {
        dialog.close()
        dialog.querySelectorAll('button').forEach((btn) => btn.removeEventListener('click', handle))
        resolve(e.currentTarget.value === 'confirm')
      }
      dialog.querySelectorAll('button').forEach((btn) => btn.addEventListener('click', handle))
    })
  }

  document.getElementById('delete-btn').addEventListener('click', async () => {
    const confirmed = await uiConfirm(document.getElementById('confirm-dlg'))
    if (confirmed) console.log('supprimé')
  })
</script>
APIDescription
uiConfirm(dialog)retourne une Promise<boolean> : true si confirmé, false si annulé
value="confirm" / value="cancel"attribut natif sur les <button> pour différencier la réponse
::backdropfond natif du <dialog>, stylable en CSS

Auto-grow Textarea

CSS + JS minimal

Zone de texte qui grandit automatiquement selon le contenu via un seul attribut oninput inline — pas de librairie, pas de ResizeObserver, juste une ligne.

<textarea class="ui-textarea" rows="2"
          style="overflow:hidden;resize:none"
          oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px'"
          placeholder="Votre message"></textarea>
TechniqueDescription
oninput inlinethis.style.height='auto' réinitialise d'abord, puis scrollHeight lit la hauteur nécessaire
overflow: hiddenmasque la barre de défilement et force scrollHeight à refléter la vraie hauteur
resize: nonesupprime la poignée de redimensionnement manuel, désormais inutile

Print Styles

CSS-first

Styles @media print pour une page imprimée propre : masquer la navigation et les boutons, adapter les couleurs, créer des sauts de page contrôlés. Pure CSS, zéro JavaScript.

Contenu principal

La navigation latérale, les boutons et le fond de page seront masqués à l'impression. Seul le contenu sémantique apparaît.

Ctrl+P / Cmd+P pour prévisualiser l'impression.

<style>
@media print {
  /* Masquer les éléments non imprimables */
  .topbar, .sidebar, .no-print,
  button, .doc-filters, .copy-btn {
    display: none !important;
  }

  /* Arrêtre-plan blanc, texte noir */
  * {
    background: white !important;
    color: black !important;
    box-shadow: none !important;
  }

  /* Liens : afficher l'URL après */
  a[href]::after { content: ' (' attr(href) ')'; font-size: 0.85em; }
  a[href^='#']::after { content: ''; } /* pas les ancres internes */

  /* Eviter les coupures au milieu d'une carte */
  .ui-card, article, section { break-inside: avoid; }

  /* Saut de page forcé avant une section importante */
  .page-break-before { break-before: page; }

  /* Fonts plus petites pour densifier */
  body { font-size: 11pt; line-height: 1.4; }
}
</style>
Règle CSSDescription
@media prints'applique uniquement à l'impression (ou en prévisualisation d'impression)
break-inside: avoidempêche une carte ou une section d'être coupée entre deux pages
break-before: pageforce un saut de page avant l'élément
content: attr(href)affiche l'URL des liens après leur texte pour le lecteur papier