Comment animer des champs de formulaire sans nuire à l'accessibilité ?

Les formulaires animés guident pendant la saisie : libellés qui flottent au-dessus du champ, soulignements qui grandissent au focus, champs qui tremblent en cas d'erreur, boutons qui montrent l'envoi puis la réussite. Ils reposent sur des transitions CSS et des sélecteurs comme :focus-visible, :placeholder-shown et :user-invalid, avec du JavaScript seulement pour les états plus riches.

Comment choisir, performances et accessibilité
  • Floating Label fait gagner de la place sur les formulaires d'inscription et de contact, Password Strength et Shake Validation donnent un retour immédiat, et Submit Button States rassure pendant les envois lents.
  • Gardez toujours un vrai élément label, annoncez les erreurs par un texte plutôt que par le seul mouvement ou la seule couleur, et vérifiez qu'aucune animation ne masque ce que l'utilisateur a tapé.
  • Floating Label et Underline Focus sont gratuits avec leur code complet ; les autres effets de formulaire sont inclus dans Premium.

Floating Label

Le label flotte vers le haut lorsque l'input est focus ou rempli.

CSS Focus Transform
Voir le code source
HTML
<div class="demo-preview">
  <div class="floating-label-group">
    <input type="text" id="floating-input" placeholder=" " />
    <label for="floating-input">Votre email</label>
  </div>
</div>
CSS
.demo-preview,
.demo-preview *,
.demo-preview *::before,
.demo-preview *::after {
  box-sizing: border-box
}

.demo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(26, 26, 36);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

/* Variables du design system du site, dont ce code dépend (valeurs de css/style.css — à adapter librement). */
.demo-preview {
  --bg-elevated: #1a1a24;
  --text-muted: #8b8b93;
}

.floating-label-group {
  position: relative;
  width: 280px;
}

.floating-label-group input {
  width: 100%;
  padding: 16px 12px 8px;
  font-size: 1rem;
  font-family: inherit;
  background: rgba(255, 255, 255, 0.05);
  border: 2px solid rgba(255, 255, 255, 0.1);
  border-radius: 8px;
  color: white;
  outline: none;
  transition: all 0.3s ease;
}

.floating-label-group label {
  position: absolute;
  left: 12px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--text-muted);
  pointer-events: none;
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  background: transparent;
  padding: 0 4px;
}

.floating-label-group input:focus,
.floating-label-group input:not(:placeholder-shown) {
  border-color: #6366f1;
  background: rgba(99, 102, 241, 0.05);
}

.floating-label-group input:focus+label,
.floating-label-group input:not(:placeholder-shown)+label {
  top: 0;
  font-size: 0.75rem;
  color: #6366f1;
  background: var(--bg-elevated);
}
JavaScript
/* CSS pur — aucun JavaScript requis */

Underline Focus

Ligne animée qui s'étend depuis le centre au focus.

CSS Focus Gradient
Voir le code source
HTML
<div class="demo-preview">
  <div class="underline-input-group">
    <input type="text" id="underline-input" placeholder=" " />
    <label for="underline-input">Nom complet</label>
    <span class="underline"></span>
  </div>
</div>
CSS
.demo-preview,
.demo-preview *,
.demo-preview *::before,
.demo-preview *::after {
  box-sizing: border-box
}

.demo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(26, 26, 36);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

/* Variables du design system du site, dont ce code dépend (valeurs de css/style.css — à adapter librement). */
.demo-preview {
  --text-muted: #8b8b93;
}

.underline-input-group {
  position: relative;
  width: 280px;
}

.underline-input-group input {
  width: 100%;
  padding: 12px 0;
  font-size: 1rem;
  font-family: inherit;
  background: transparent;
  border: none;
  border-bottom: 2px solid rgba(255, 255, 255, 0.2);
  color: white;
  outline: none;
}

.underline-input-group .underline {
  position: absolute;
  bottom: 0;
  left: 50%;
  width: 0;
  height: 2px;
  background: linear-gradient(90deg, #6366f1, #d946ef);
  transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}

.underline-input-group input:focus~.underline {
  left: 0;
  width: 100%;
}

.underline-input-group label {
  position: absolute;
  left: 0;
  top: 12px;
  color: var(--text-muted);
  pointer-events: none;
  transition: all 0.3s ease;
}

.underline-input-group input:focus+label,
.underline-input-group input:not(:placeholder-shown)+label {
  top: -16px;
  font-size: 0.75rem;
  color: #6366f1;
}
JavaScript
/* CSS pur — aucun JavaScript requis */

Border Animation

Bordure qui se dessine progressivement autour de l'input.

CSS Focus Border

Shake Validation

L'input tremble pour indiquer une erreur de validation.

JS Animation Validation

Success Checkmark

Coche animée qui apparait quand l'input est valide.

JS SVG Validation

Password Strength

Indicateur animé de la force du mot de passe.

JS Animation Security

Search Expand

Champ de recherche qui s'étend au clic.

CSS Focus Transform

Input with Icon

Icône qui s'anime au focus de l'input.

CSS Focus Icon

Textarea Auto-grow

Zone de texte qui s'agrandit automatiquement.

JS Resize Interactive

Submit Button States

Bouton avec états loading, success et error animés.

JS Animation States

Floating Label V2

Label qui flotte élégamment au-dessus de l'input avec fond coloré.

CSS Focus Transform

Underline Animation

Ligne qui s'étend depuis le centre lors du focus.

CSS Focus Pseudo-element

Glow Input

Input avec effet de lueur au focus.

CSS Box-shadow Focus

Checkbox Animated

Checkbox avec coche animée qui apparait progressivement.

CSS Transform Checkbox

Toggle iOS Style

Toggle switch avec animation élastique style iOS.

CSS Cubic-bezier Toggle

Radio Ripple

Boutons radio avec effet ripple lors de la sélection.

CSS Animation Radio

Password Strength V2

Indicateur de force compact avec barre colorée.

JS Animation Security

Select Animated

Menu déroulant avec flèche animée au focus.

CSS Transform Select

Character Counter

Textarea avec compteur de caractères intégré.

JS Counter Textarea

Search Expand V2

Champ de recherche compact qui s'étend au focus.

CSS Focus Width

Pricing Toggle Odometer

Bascule mensuel/annuel dont le prix défile chiffre par chiffre (effet compteur) avec badge d'économie animé. Idéal pour pousser l'abonnement annuel.

💡 Idéal pour : page pricing, SaaS, comparateur d'offres, upsell annuel, Black Friday.

Vanilla JS Odomètre Pricing Premium

Mes résolutions 2027

12 jours de suite

Année lancée

Resolution Tracker

Cases à cocher avec remplissage à ressort, coche SVG tracée et barrage ondulé dessiné. Le trait se trace d'abord (350 ms) puis la ligne glisse en douceur (FLIP multi-items). L'anneau de progression se remplit avec un éclat à chaque palier ; à 5/5, célébration sobre (halo doré, particules, message). Tout se décoche en cascade, puis la démo recommence.

💡 Idéal pour : écran d'accueil d'une appli d'habitudes, widget de suivi d'objectifs du Nouvel An, landing page de productivité.

Forms Animation FLIP Progress
Voir le code source
HTML
<div class="demo-preview">

      <div class="rcl-card" data-celebration-msg="Année lancée" data-progress-label="Progression : {n} sur {t}">

        <header class="rcl-header">
          <div class="rcl-header-info">
            <p class="rcl-title">Mes résolutions 2027</p>
            <span class="rcl-streak" aria-label="12 jours de suite">
              <svg class="rcl-flame" viewBox="0 0 12 16" aria-hidden="true"><path d="M6 1C5 4 3 5 3 8a3 3 0 006 0C9 5 7 4 6 1zm-1.5 9c0-1.5 1.5-2 1.5-3.5 0 1.5 1.5 2 1.5 3.5a1.5 1.5 0 01-3 0z"/></svg>
              <span class="rcl-streak-count">12</span>
              <span class="rcl-streak-suffix">jours de suite</span>
            </span>
          </div>
          <div class="rcl-ring-wrap">
            <svg class="rcl-ring" viewBox="0 0 100 100"
                 role="img" aria-label="Progression : 0 sur 5">
              <defs>
                <linearGradient id="rcl-grad" x1="0" y1="0" x2="1" y2="1">
                  <stop offset="0%" stop-color="#8b5cf6"/>
                  <stop offset="100%" stop-color="#f59e0b"/>
                </linearGradient>
              </defs>
              <circle class="rcl-ring-bg" cx="50" cy="50" r="44"/>
              <circle class="rcl-ring-fill" cx="50" cy="50" r="44"/>
            </svg>
            <div class="rcl-ring-text" aria-hidden="true">
              <span class="rcl-count">0</span>
              <span class="rcl-count-total">/5</span>
            </div>
          </div>
        </header>

        <ul class="rcl-list" role="list" aria-label="Résolutions 2027">
          <li class="rcl-item" role="listitem" style="--acc:#a78bfa">
            <label class="rcl-label">
              <input type="checkbox" class="rcl-check-input">
              <span class="rcl-icon" aria-hidden="true"><svg viewBox="0 0 16 16"><path d="M8 2a1 1 0 1 0 0-2M5 5l-2 7h3M5 5l2 5 3-5"/></svg></span>
              <span class="rcl-checkbox" aria-hidden="true">
                <svg class="rcl-check-svg" viewBox="0 0 20 20" aria-hidden="true">
                  <path class="rcl-check-path" d="M4 10 l4.5 4.5 L16 6.5"/>
                </svg>
              </span>
              <span class="rcl-text-wrap">
                <span class="rcl-text">Courir 3 fois par semaine</span>
              </span>
            </label>
          </li>
          <li class="rcl-item" role="listitem" style="--acc:#60a5fa">
            <label class="rcl-label">
              <input type="checkbox" class="rcl-check-input">
              <span class="rcl-icon" aria-hidden="true"><svg viewBox="0 0 16 16"><path d="M3 2h5v12H3zM8 2h5v12H8z"/></svg></span>
              <span class="rcl-checkbox" aria-hidden="true">
                <svg class="rcl-check-svg" viewBox="0 0 20 20" aria-hidden="true">
                  <path class="rcl-check-path" d="M4 10 l4.5 4.5 L16 6.5"/>
                </svg>
              </span>
              <span class="rcl-text-wrap">
                <span class="rcl-text">Lire 12 livres</span>
              </span>
            </label>
          </li>
          <li class="rcl-item" role="listitem" style="--acc:#34d399">
            <label class="rcl-label">
              <input type="checkbox" class="rcl-check-input">
              <span class="rcl-icon" aria-hidden="true"><svg viewBox="0 0 16 16"><circle cx="8" cy="6" r="4"/><path d="M5 14c1-2 3-3 3-3s2 1 3 3"/></svg></span>
              <span class="rcl-checkbox" aria-hidden="true">
                <svg class="rcl-check-svg" viewBox="0 0 20 20" aria-hidden="true">
                  <path class="rcl-check-path" d="M4 10 l4.5 4.5 L16 6.5"/>
                </svg>
              </span>
              <span class="rcl-text-wrap">
                <span class="rcl-text">Méditer chaque matin</span>
              </span>
            </label>
          </li>
          <li class="rcl-item" role="listitem" style="--acc:#fbbf24">
            <label class="rcl-label">
              <input type="checkbox" class="rcl-check-input">
              <span class="rcl-icon" aria-hidden="true"><svg viewBox="0 0 16 16"><path d="M3 7h10v7H3zM6 7V4M10 7V4"/></svg></span>
              <span class="rcl-checkbox" aria-hidden="true">
                <svg class="rcl-check-svg" viewBox="0 0 20 20" aria-hidden="true">
                  <path class="rcl-check-path" d="M4 10 l4.5 4.5 L16 6.5"/>
                </svg>
              </span>
              <span class="rcl-text-wrap">
                <span class="rcl-text">Cuisiner maison 4×/semaine</span>
              </span>
            </label>
          </li>
          <li class="rcl-item" role="listitem" style="--acc:#f472b6">
            <label class="rcl-label">
              <input type="checkbox" class="rcl-check-input">
              <span class="rcl-icon" aria-hidden="true"><svg viewBox="0 0 16 16"><path d="M11 2v8M8 10a2 2 0 1 0 .01 0M11 2 8 4"/></svg></span>
              <span class="rcl-checkbox" aria-hidden="true">
                <svg class="rcl-check-svg" viewBox="0 0 20 20" aria-hidden="true">
                  <path class="rcl-check-path" d="M4 10 l4.5 4.5 L16 6.5"/>
                </svg>
              </span>
              <span class="rcl-text-wrap">
                <span class="rcl-text">Apprendre le piano</span>
              </span>
            </label>
          </li>
        </ul>

        <div class="rcl-celebration" role="status" aria-live="polite" aria-atomic="true">
          <canvas class="rcl-celebration-canvas" aria-hidden="true"></canvas>
          <p class="rcl-celebration-msg">Année lancée</p>
        </div>

      </div><!-- .rcl-card -->

    </div>
CSS
*,
*::before,
*::after { box-sizing: border-box }

body { margin: 0; background: #0a0a0f; color: #fff; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: Inter, -apple-system, sans-serif; overflow: hidden; }

.demo-preview { display: flex; align-items: center; justify-content: center; width: 100%; min-height: 300px; position: relative; }

/* Card container */
.rcl-card {
  background: linear-gradient(160deg, #16161f 0%, #111119 100%);
  border: 1px solid rgba(255,255,255,0.06);
  border-radius: 20px;
  padding: 14px 16px;
  width: 100%;
  max-width: 330px;
  position: relative;
  overflow: visible;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  box-sizing: border-box;
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.05),
    inset 0 0 0 1px rgba(0,0,0,0.6),
    0 20px 60px rgba(0,0,0,0.5),
    0 4px 16px rgba(0,0,0,0.35);
}

/* Header */
.rcl-header {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 8px;
  padding-bottom: 8px;
  border-bottom: 1px solid rgba(255,255,255,0.05);
}

.rcl-header-info {
  flex: 1;
  min-width: 0;
}

.rcl-title {
  font-size: 13.5px;
  font-weight: 800;
  color: #f4f4f5;
  letter-spacing: -0.025em;
  margin: 0 0 3px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.rcl-streak-count { font-weight: 600; font-variant-numeric: tabular-nums; }
.rcl-streak-suffix { opacity: .85; }
.rcl-streak {
  font-size: 11px;
  color: #52525b;
  display: flex;
  align-items: center;
  gap: 4px;
}

/* Flame SVG icon */
.rcl-flame {
  width: 11px;
  height: 13px;
  fill: #f97316;
  flex-shrink: 0;
}

/* Progress ring */
.rcl-ring-wrap {
  position: relative;
  flex-shrink: 0;
  width: 56px;
  height: 56px;
}

.rcl-ring {
  width: 56px;
  height: 56px;
  transform: rotate(-90deg);
  transform-origin: 50% 50%;
  overflow: visible;
  display: block;
}

.rcl-ring-bg {
  fill: none;
  stroke: #1c1c2a;
  stroke-width: 7;
}

.rcl-ring-fill {
  fill: none;
  stroke: url(#rcl-grad);
  stroke-width: 7;
  stroke-linecap: round;
  transition: stroke-dashoffset 0.5s cubic-bezier(0.34, 1.56, 0.64, 1),
              filter 0.5s ease;
}

.rcl-ring.is-complete .rcl-ring-fill {
  filter: drop-shadow(0 0 8px rgba(245,158,11,0.65));
}

@keyframes rcl-ring-pulse {
  0%   { filter: drop-shadow(0 0 0 transparent); }
  40%  { filter: drop-shadow(0 0 9px rgba(139,92,246,0.7)); }
  100% { filter: drop-shadow(0 0 2px rgba(139,92,246,0.1)); }
}

.rcl-ring-fill.rcl-pulsed {
  animation: rcl-ring-pulse 0.65s ease-out forwards;
}

.rcl-ring-text {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}

.rcl-count {
  font-size: 15px;
  font-weight: 800;
  color: #f4f4f5;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.03em;
}

.rcl-count-total {
  font-size: 9px;
  color: #52525b;
  line-height: 1;
  margin-top: 2px;
}

/* List */
.rcl-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

/* Item */
.rcl-item {
  position: relative;
  will-change: transform;
}

/* Label row */
.rcl-label {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 6px 8px;
  border-radius: 9px;
  cursor: pointer;
  position: relative;
  transition: background 0.12s;
  outline: none;
}

.rcl-label:hover { background: rgba(255,255,255,0.04); }

/* Focus ring only on keyboard navigation */
.rcl-label:has(.rcl-check-input:focus-visible) {
  background: rgba(139,92,246,0.08);
  outline: 2px solid rgba(139,92,246,0.45);
  outline-offset: 1px;
}

/* Native checkbox — invisible but accessible */
.rcl-check-input {
  position: absolute;
  opacity: 0;
  width: 22px;
  height: 22px;
  margin: 0;
  cursor: pointer;
  z-index: 2;
  left: 8px;
  top: 50%;
  transform: translateY(-50%);
}

/* Item icon */
.rcl-icon {
  width: 13px;
  height: 13px;
  flex-shrink: 0;
  color: var(--acc, #7c3aed);
  transition: color 0.3s;
  pointer-events: none;
}

.rcl-icon svg {
  width: 100%;
  height: 100%;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.rcl-item.is-done .rcl-icon { color: #3a3a52; }

/* Custom checkbox visual */
.rcl-checkbox {
  width: 20px;
  height: 20px;
  border: 1.5px solid #38384e;
  border-radius: 6px;
  flex-shrink: 0;
  position: relative;
  transition: border-color 0.2s;
  pointer-events: none;
  box-sizing: border-box;
}

@keyframes rcl-spring-fill {
  0%   { transform: scale(0); }
  52%  { transform: scale(1.18); }
  72%  { transform: scale(0.91); }
  86%  { transform: scale(1.06); }
  100% { transform: scale(1); }
}

@keyframes rcl-spring-unfill {
  0%   { transform: scale(1); }
  40%  { transform: scale(1.08); }
  100% { transform: scale(0); }
}

.rcl-checkbox::before {
  content: '';
  position: absolute;
  inset: -1px;
  background: #8b5cf6;
  border-radius: 5px;
  transform: scale(0);
  transform-origin: center;
}

.rcl-item.is-done .rcl-checkbox {
  border-color: #8b5cf6;
}

.rcl-item.is-done .rcl-checkbox::before {
  animation: rcl-spring-fill 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}

.rcl-item.unchecking .rcl-checkbox::before {
  animation: rcl-spring-unfill 0.25s ease-in forwards;
}

/* SVG checkmark */
.rcl-check-svg {
  position: absolute;
  inset: 1px;
  width: calc(100% - 2px);
  height: calc(100% - 2px);
  pointer-events: none;
  z-index: 1;
  overflow: visible;
}

.rcl-check-path {
  fill: none;
  stroke: #fff;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 20;
  stroke-dashoffset: 20;
  transition: stroke-dashoffset 0.25s cubic-bezier(0.22, 1, 0.36, 1) 0.08s;
}

.rcl-item.is-done .rcl-check-path {
  stroke-dashoffset: 0;
}

.rcl-item.unchecking .rcl-check-path {
  stroke-dashoffset: 20;
  transition: stroke-dashoffset 0.15s ease-in;
}

/* Text + strikethrough wrapper */
.rcl-text-wrap {
  position: relative;
  flex: 1;
  min-width: 0;
  overflow: hidden;
}

.rcl-text {
  font-size: 12.5px;
  font-weight: 500;
  color: #d4d4d8;
  line-height: 1.4;
  display: block;
  transition: color 0.3s 0.05s;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  letter-spacing: -0.005em;
}

.rcl-item.is-done .rcl-text { color: #3f3f56; }

/* Animated wavy strikethrough */
.rcl-strike-svg {
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 100%;
  height: 8px;
  pointer-events: none;
  overflow: visible;
}

.rcl-strike-path {
  fill: none;
  stroke: none;
  stroke-width: 1.2;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}

.rcl-item.is-done .rcl-strike-path {
  stroke: #4a4a68;
  /* dashoffset set by JS; transition to 0 draws left→right over 350ms */
  transition: stroke-dashoffset 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}

.rcl-item.unchecking .rcl-strike-path {
  stroke: #4a4a68;
  transition: stroke-dashoffset 0.15s ease-in;
}

/* Celebration overlay */
.rcl-celebration {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.35s ease;
  z-index: 10;
  border-radius: 20px;
  background: rgba(8,8,18,0.74);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}

.rcl-celebration.is-visible { opacity: 1; }

.rcl-celebration-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: 20px;
  pointer-events: none;
}

.rcl-celebration-msg {
  font-size: 18px;
  font-weight: 800;
  color: #f59e0b;
  letter-spacing: -0.01em;
  margin: 0;
  position: relative;
  z-index: 1;
  text-shadow: 0 0 28px rgba(245,158,11,0.5);
  transform: translateY(10px) scale(0.92);
  opacity: 0;
  transition: transform 0.48s cubic-bezier(0.34, 1.56, 0.64, 1) 0.15s,
              opacity 0.3s ease 0.15s;
}

.rcl-celebration.is-visible .rcl-celebration-msg {
  transform: translateY(0) scale(1);
  opacity: 1;
}

/* Ring halo at 5/5 */
@keyframes rcl-ring-halo {
  0%,100% { opacity:1; transform: rotate(-90deg) scale(1); }
  50%      { opacity:0.85; transform: rotate(-90deg) scale(1.06); }
}

.rcl-ring.is-complete {
  animation: rcl-ring-halo 1.8s ease-in-out infinite;
  transform-origin: 50% 50%;
}

/* prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
  .rcl-checkbox::before,
  .rcl-item.is-done .rcl-checkbox::before,
  .rcl-item.unchecking .rcl-checkbox::before {
    animation: none !important;
    transform: scale(var(--rcl-fill-final, 0));
    transition: transform 0.15s linear;
  }
  .rcl-ring-fill,
  .rcl-ring-fill.rcl-pulsed,
  .rcl-check-path,
  .rcl-item.unchecking .rcl-check-path,
  .rcl-celebration,
  .rcl-celebration-msg,
  .rcl-text,
  .rcl-label { transition: none !important; animation: none !important; }
  .rcl-strike-path,
  .rcl-item.unchecking .rcl-strike-path { transition: none !important; }
  .rcl-ring.is-complete { animation: none !important; }
}

@media (prefers-reduced-transparency: reduce) {
  .rcl-celebration { backdrop-filter: none; -webkit-backdrop-filter: none; background: rgba(8,8,18,0.93); }
}

/* High-contrast */
.rcl-card.high-contrast .rcl-text { color: #fff; }
.rcl-card.high-contrast .rcl-item.is-done .rcl-text { color: #71717a; }
.rcl-card.high-contrast .rcl-checkbox { border-color: #fff; border-width: 2px; }
.rcl-card.high-contrast .rcl-strike-path { stroke: #a1a1aa; stroke-width: 2; }
.rcl-card.high-contrast .rcl-celebration-msg { color: #ffd700; text-shadow: none; }
JavaScript
(function () {
  'use strict';

  var REDUCED = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var CIRC = 2 * Math.PI * 44;

  /* ── Inject wavy strikethrough SVG */
  function injectStrike(textWrap) {
    var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.classList.add('rcl-strike-svg');
    svg.setAttribute('viewBox', '0 0 100 8');
    svg.setAttribute('preserveAspectRatio', 'none');
    svg.setAttribute('aria-hidden', 'true');

    var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    path.classList.add('rcl-strike-path');
    path.setAttribute('d', 'M 1 4 C 12 2.6 22 5.4 33 4 C 44 2.6 54 5.4 65 4 C 76 2.6 86 5.4 99 4');
    svg.appendChild(path);
    textWrap.appendChild(svg);

    requestAnimationFrame(function () {
      var len = path.getTotalLength() || 99;
      path.style.strokeDasharray = len;
      path.style.strokeDashoffset = len;

      var textEl = textWrap.querySelector('.rcl-text');
      if (textEl && textEl.textContent) {
        var mc = document.createElement('canvas');
        var ct = mc.getContext('2d');
        if (ct) {
          var cs = window.getComputedStyle(textEl);
          ct.font = [cs.fontStyle, cs.fontWeight, cs.fontSize, cs.fontFamily].filter(Boolean).join(' ');
          var tw = ct.measureText(textEl.textContent).width;
          svg.style.width = Math.ceil(Math.min(tw + 4, textWrap.offsetWidth)) + 'px';
        }
      }
    });
    return path;
  }

  /* ── FLIP: cancel any in-progress transforms, then animate to bottom */
  function flipToBottom(item, list) {
    /* Step 0 — commit any in-flight FLIP so we measure true positions */
    var all = Array.prototype.slice.call(list.children);
    var hadFlight = false;
    all.forEach(function (el) {
      if (el.style.transform && el.style.transform !== '') {
        el.style.transition = '';
        el.style.transform = '';
        hadFlight = true;
      }
    });
    if (hadFlight) void list.offsetHeight;

    /* Step 1 — FIRST positions */
    var firstMap = new Map();
    all.forEach(function (el) { firstMap.set(el, el.getBoundingClientRect().top); });

    /* Step 2 — mutate DOM */
    list.appendChild(item);

    /* Step 3 — apply inverse transforms */
    var toAnimate = [];
    Array.prototype.slice.call(list.children).forEach(function (child) {
      var first = firstMap.get(child);
      if (first === undefined) return;
      var dy = first - child.getBoundingClientRect().top;
      if (Math.abs(dy) < 0.5) return;
      child.style.transition = 'none';
      child.style.transform = 'translateY(' + dy + 'px)';
      toAnimate.push(child);
    });
    if (!toAnimate.length) return;
    void list.offsetHeight;

    /* Step 4 — animate to natural position */
    toAnimate.forEach(function (el) {
      el.style.transition = 'transform 0.38s cubic-bezier(0.22, 1, 0.36, 1)';
      el.style.transform = '';
      el.addEventListener('transitionend', function h(e) {
        if (e.propertyName !== 'transform') return;
        el.removeEventListener('transitionend', h);
        el.style.transition = '';
      });
    });
  }

  /* ── Update ring + pulse glow */
  function updateRing(card, count, total) {
    var ringFill = card.querySelector('.rcl-ring-fill');
    var ring = card.querySelector('.rcl-ring');
    var countEl = card.querySelector('.rcl-count');

    var offset = CIRC * (1 - count / total);
    ringFill.style.strokeDasharray = CIRC;
    ringFill.style.strokeDashoffset = offset;
    if (countEl) countEl.textContent = count;
    ring.setAttribute('aria-label', (card.dataset.progressLabel || '{n} / {t}').replace('{n}', count).replace('{t}', total));

    if (count === total) {
      ring.classList.add('is-complete');
    } else {
      ring.classList.remove('is-complete');
    }

    /* Pulse glow on each step */
    if (!REDUCED && count > 0) {
      ringFill.classList.remove('rcl-pulsed');
      void ringFill.offsetWidth;
      ringFill.classList.add('rcl-pulsed');
    }
  }

  /* ── Canvas particles */
  function burstParticles(canvas) {
    if (!canvas || REDUCED) return;
    var parent = canvas.parentElement;
    var rect = parent.getBoundingClientRect();
    canvas.width = rect.width || 330;
    canvas.height = rect.height || 250;
    var ctx = canvas.getContext('2d');
    if (!ctx) return;

    var cx = canvas.width * 0.5;
    var cy = canvas.height * 0.4;
    var COLORS = ['#f59e0b', '#fbbf24', '#fcd34d', '#d97706', '#fde68a', '#fff7ed'];
    var particles = [];

    for (var i = 0; i < 36; i++) {
      var angle = Math.random() * Math.PI * 2;
      var speed = 1.8 + Math.random() * 3.2;
      var isSquare = Math.random() > 0.55;
      particles.push({
        x: cx, y: cy,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed - (0.5 + Math.random() * 1.5),
        r: isSquare ? 2 + Math.random() * 3 : 1.5 + Math.random() * 2.5,
        alpha: 1,
        color: COLORS[Math.floor(Math.random() * COLORS.length)],
        rot: Math.random() * Math.PI * 2,
        rotV: (Math.random() - 0.5) * 0.22,
        square: isSquare
      });
    }

    function frame() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      var alive = false;
      particles.forEach(function (pt) {
        pt.x += pt.vx; pt.y += pt.vy; pt.vy += 0.09;
        pt.alpha -= 0.016; pt.rot += pt.rotV;
        if (pt.alpha > 0) {
          alive = true;
          ctx.save();
          ctx.globalAlpha = Math.max(0, pt.alpha);
          ctx.translate(pt.x, pt.y);
          ctx.rotate(pt.rot);
          ctx.fillStyle = pt.color;
          if (pt.square) {
            ctx.fillRect(-pt.r, -pt.r, pt.r * 2, pt.r * 2);
          } else {
            ctx.beginPath();
            ctx.moveTo(0, -pt.r * 1.5);
            ctx.lineTo(pt.r * 0.6, 0);
            ctx.lineTo(0, pt.r * 1.5);
            ctx.lineTo(-pt.r * 0.6, 0);
            ctx.closePath();
            ctx.fill();
          }
          ctx.restore();
        }
      });
      if (alive) requestAnimationFrame(frame);
      else ctx.clearRect(0, 0, canvas.width, canvas.height);
    }
    requestAnimationFrame(frame);
  }

  /* ── Init one .rcl-card */
  function initCard(card) {
    var list = card.querySelector('.rcl-list');
    var inputs = Array.prototype.slice.call(card.querySelectorAll('.rcl-check-input'));
    var celebration = card.querySelector('.rcl-celebration');
    var celebMsg = card.querySelector('.rcl-celebration-msg');
    var celebCanvas = card.querySelector('.rcl-celebration-canvas');
    var TOTAL = inputs.length;
    if (!list || !TOTAL) return;

    /* Celebration text from HTML attribute */
    var celebText = card.dataset.celebrationMsg || 'Bonne année';
    if (celebMsg) celebMsg.textContent = celebText;

    /* Init ring */
    var ringFill = card.querySelector('.rcl-ring-fill');
    if (ringFill) { ringFill.style.strokeDasharray = CIRC; ringFill.style.strokeDashoffset = CIRC; }

    /* Inject strikethroughs */
    var strikePaths = new WeakMap();
    inputs.forEach(function (inp) {
      var tw = inp.closest('.rcl-item').querySelector('.rcl-text-wrap');
      if (tw) strikePaths.set(inp, injectStrike(tw));
    });

    /* Original DOM order */
    var origOrder = Array.prototype.slice.call(list.querySelectorAll('.rcl-item'));

    function countChecked() {
      return inputs.filter(function (i) { return i.checked; }).length;
    }

    /* --- Check item: strikethrough first, FLIP after 350ms --- */
    function checkItem(item, inp) {
      if (item.classList.contains('is-done')) return;
      item.classList.remove('unchecking');
      item.classList.add('is-done');
      item.querySelector('.rcl-checkbox').style.setProperty('--rcl-fill-final', '1');

      /* Start strikethrough immediately */
      var sp = strikePaths.get(inp);
      if (sp) requestAnimationFrame(function () { sp.style.strokeDashoffset = '0'; });

      /* FLIP after strikethrough draws (~350ms) */
      if (!REDUCED) {
        setTimeout(function () { flipToBottom(item, list); }, 350);
      } else {
        list.appendChild(item);
      }

      var n = countChecked();
      updateRing(card, n, TOTAL);

      /* Celebration fires after last FLIP completes (350ms strike + 380ms flip + buffer) */
      if (n === TOTAL) setTimeout(triggerCelebration, 850);
    }

    /* --- Uncheck item --- */
    function uncheckItem(item, inp, silent) {
      if (!item.classList.contains('is-done')) return;
      item.classList.add('unchecking');
      item.classList.remove('is-done');
      item.querySelector('.rcl-checkbox').style.setProperty('--rcl-fill-final', '0');

      var sp = strikePaths.get(inp);
      if (sp) {
        var len = parseFloat(sp.style.strokeDasharray) || 102;
        sp.style.strokeDashoffset = len;
      }

      setTimeout(function () { item.classList.remove('unchecking'); }, 300);
      updateRing(card, countChecked(), TOTAL);
      if (!silent) resetAutoTimer();
    }

    /* --- Celebration --- */
    function triggerCelebration() {
      if (!celebration) return;
      celebration.classList.add('is-visible');
      burstParticles(celebCanvas);

      setTimeout(function () {
        celebration.classList.remove('is-visible');

        /* Cascade uncheck (bottom → top) */
        var done = Array.prototype.slice.call(list.querySelectorAll('.rcl-item.is-done')).reverse();
        done.forEach(function (item, idx) {
          setTimeout(function () {
            var inp = item.querySelector('.rcl-check-input');
            if (inp) { inp.checked = false; uncheckItem(item, inp, true); }
            if (idx === done.length - 1) {
              setTimeout(function () {
                origOrder.forEach(function (it) { list.appendChild(it); });
                updateRing(card, 0, TOTAL);
                card.querySelector('.rcl-ring').classList.remove('is-complete');
                autoRunning = false;
                resetAutoTimer();
              }, 350);
            }
          }, idx * 140);
        });
      }, 2500);
    }

    /* --- User interaction --- */
    inputs.forEach(function (inp) {
      var item = inp.closest('.rcl-item');
      inp.addEventListener('change', function () {
        if (inp.checked) { checkItem(item, inp); resetAutoTimer(); }
        else { uncheckItem(item, inp, false); }
      });
    });

    /* --- Auto-play sequence --- */
    var autoTimer = null;
    var autoRunning = false;

    function resetAutoTimer() {
      if (autoTimer) clearTimeout(autoTimer);
      if (!autoRunning) autoTimer = setTimeout(startAutoSequence, 5000);
    }

    function startAutoSequence() {
      autoRunning = true;

      /* Full reset */
      inputs.forEach(function (inp) { inp.checked = false; });
      Array.prototype.slice.call(list.querySelectorAll('.rcl-item')).forEach(function (item) {
        /* Cancel in-flight transforms */
        item.style.transition = '';
        item.style.transform = '';
        item.classList.remove('is-done', 'unchecking');
        item.querySelector('.rcl-checkbox').style.setProperty('--rcl-fill-final', '0');
        var inp2 = item.querySelector('.rcl-check-input');
        var sp = inp2 ? strikePaths.get(inp2) : null;
        if (sp) { sp.style.strokeDashoffset = parseFloat(sp.style.strokeDasharray) || 102; }
      });
      origOrder.forEach(function (it) { list.appendChild(it); });
      updateRing(card, 0, TOTAL);
      card.querySelector('.rcl-ring').classList.remove('is-complete');

      /* Check items one by one; 900ms gap ensures each strike+FLIP completes */
      var seq = inputs.slice(); /* copy — snapshot of current inputs */
      var idx = 0;
      function next() {
        if (idx >= seq.length) return;
        var inp = seq[idx++];
        inp.checked = true;
        checkItem(inp.closest('.rcl-item'), inp);
        if (idx < seq.length) setTimeout(next, 900);
      }
      setTimeout(next, 600);
    }

    /* Pause when off-screen */
    if (typeof IntersectionObserver !== 'undefined') {
      new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) { if (autoTimer) clearTimeout(autoTimer); }
          else if (!autoRunning) resetAutoTimer();
        });
      }, { threshold: 0.1 }).observe(card);
    }

    resetAutoTimer();
  }

  /* ── Init all cards */
  function init() { document.querySelectorAll('.rcl-card').forEach(initCard); }

  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
  else init();

  window.ResolutionTracker = { init: init };
})();

Ink Signature Pledge

Un vrai pad de signature rendu stylo-plume : le biseau de la plume donne des pleins et des déliés selon la direction, la vitesse et la pression du stylet, l'encre bleu-noir s'accumule au poser, bave finement dans les fibres du papier crème, s'assombrit aux croisements et sèche sous vos yeux (brillante → mate en 1,5 s). Au repos, une signature manuscrite se trace seule au rythme d'une main. Annuler, effacer, valider : export PNG transparent + SVG et événement sgn:sign. Alternative clavier (nom tapé en cursive), prefers-reduced-motion → tracé direct.

💡 Idéal pour : « contrat avec soi-même » de bonne année, acceptation de devis ou de CGV, e-signature de contrat, bon pour accord, livre d'or — le même composant sert partout.

Canvas Formulaire Manuscrit Papier Premium

Questions fréquentes

Comment créer un label flottant en CSS ? +

Placez le label après l'input dans le HTML. Utilisez le sélecteur :placeholder-shown pour détecter si l'input est vide, et le sélecteur :focus pour l'état actif. Animez la position et la taille du label avec transform et transition.

Comment styliser la validation d'un formulaire en CSS ? +

Utilisez les pseudo-classes :valid et :invalid pour changer le style des inputs selon leur état. Ajoutez une border verte ou rouge avec une transition douce. Combinez avec :not(:placeholder-shown) pour ne valider que les champs remplis.

Les formulaires animés sont-ils accessibles ? +

Oui, à condition de garder les labels associés aux inputs (attribut for/id), de maintenir un contraste suffisant, et de ne pas masquer les messages d'erreur derrière des animations. Testez toujours avec un lecteur d'écran.

Comment animer le focus d'un input ? +

Utilisez le sélecteur :focus-within sur le conteneur et :focus sur l'input. Animez la border, ajoutez un box-shadow coloré, ou faites apparaitre une ligne animée avec un pseudo-élément ::after et une transition de width.

Quel est le meilleur framework pour des formulaires animés ? +

Pour des formulaires simples, le CSS pur suffit avec les sélecteurs :focus, :valid, :invalid. Pour des formulaires complexes, combinez avec JavaScript vanilla pour la validation en temps réel. Pas besoin de framework — Effect.Labs fournit le code CSS prêt à l'emploi.