Formulaires & Inputs Animes
Formulaires animés et inputs interactifs pour améliorer l'UX.
Mis à jour le
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.
Voir le code source
<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>.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);
}/* CSS pur — aucun JavaScript requis */Underline Focus
Ligne animée qui s'étend depuis le centre au focus.
Voir le code source
<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>.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;
}/* CSS pur — aucun JavaScript requis */Border Animation
Bordure qui se dessine progressivement autour de l'input.
Shake Validation
L'input tremble pour indiquer une erreur de validation.
Success Checkmark
Coche animée qui apparait quand l'input est valide.
Password Strength
Indicateur animé de la force du mot de passe.
Search Expand
Champ de recherche qui s'étend au clic.
Input with Icon
Icône qui s'anime au focus de l'input.
Textarea Auto-grow
Zone de texte qui s'agrandit automatiquement.
Submit Button States
Bouton avec états loading, success et error animés.
Floating Label V2
Label qui flotte élégamment au-dessus de l'input avec fond coloré.
Underline Animation
Ligne qui s'étend depuis le centre lors du focus.
Glow Input
Input avec effet de lueur au focus.
Checkbox Animated
Checkbox avec coche animée qui apparait progressivement.
Toggle iOS Style
Toggle switch avec animation élastique style iOS.
Radio Ripple
Boutons radio avec effet ripple lors de la sélection.
Password Strength V2
Indicateur de force compact avec barre colorée.
Select Animated
Menu déroulant avec flèche animée au focus.
Character Counter
Textarea avec compteur de caractères intégré.
Search Expand V2
Champ de recherche compact qui s'étend au focus.
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.
Mes résolutions 2027
12 jours de suiteAnné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é.
Voir le code source
<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>*,
*::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; }(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.