22 CSS Transitions13 / 22
Data Attribute State Machine Transitions
One element moving through idle, loading, success and error, driven entirely by a single data-state attribute, with every visual difference expressed as a transition between attribute selectors. Argues against class juggling: one attribute is one source of truth and cannot land in an impossible combined state the way four independent booleans can.
Published Updated
The code
<section class="trn-13" aria-labelledby="trn-13-heading">
<div class="trn-13__stage">
<div class="trn-13__theme">
<input class="trn-13__themecb" type="checkbox" id="trn-13-theme">
<label class="trn-13__themebtn" for="trn-13-theme">
<span class="trn-13__themeico" aria-hidden="true">
<svg class="trn-13__sun" viewBox="0 0 20 20" width="15" height="15"><circle cx="10" cy="10" r="3.9" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 1.7v2.1M10 16.2v2.1M1.7 10h2.1M16.2 10h2.1M4.2 4.2l1.5 1.5M14.3 14.3l1.5 1.5M15.8 4.2l-1.5 1.5M5.7 14.3l-1.5 1.5" stroke="currentColor" stroke-width="1.4"/></svg>
<svg class="trn-13__moon" viewBox="0 0 20 20" width="15" height="15"><path d="M15.4 12.7A6.7 6.7 0 0 1 7.3 4.6a6.8 6.8 0 1 0 8.1 8.1Z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</span>
<span>Theme</span>
</label>
</div>
<header class="trn-13__head">
<p class="trn-13__kicker">[data-state]</p>
<h2 class="trn-13__heading" id="trn-13-heading">One attribute, four states, zero impossible combinations</h2>
<p class="trn-13__sub">Every transition below is a change of one attribute value. No classes are added or removed.</p>
</header>
<div class="trn-13__machine" id="trn-13-machine" data-state="idle">
<div class="trn-13__chip">
<span class="trn-13__glyph" aria-hidden="true">
<svg class="trn-13__ico trn-13__ico--idle" viewBox="0 0 20 20" width="18" height="18"><circle cx="10" cy="10" r="7.2" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
<svg class="trn-13__ico trn-13__ico--load" viewBox="0 0 20 20" width="18" height="18"><path d="M10 2.8a7.2 7.2 0 1 1-7.1 8.4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<svg class="trn-13__ico trn-13__ico--ok" viewBox="0 0 20 20" width="18" height="18"><path d="m4.6 10.4 3.3 3.3 7.5-7.6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
<svg class="trn-13__ico trn-13__ico--err" viewBox="0 0 20 20" width="18" height="18"><path d="M5.4 5.4l9.2 9.2M14.6 5.4l-9.2 9.2" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
</span>
<span class="trn-13__label" id="trn-13-label" role="status">Ready to deploy</span>
<code class="trn-13__value" id="trn-13-value">data-state="idle"</code>
</div>
<div class="trn-13__meter"><span class="trn-13__fill"></span></div>
</div>
<div class="trn-13__controls">
<button class="trn-13__btn" type="button" data-trn-13-set="idle">idle</button>
<button class="trn-13__btn" type="button" data-trn-13-set="loading">loading</button>
<button class="trn-13__btn" type="button" data-trn-13-set="success">success</button>
<button class="trn-13__btn" type="button" data-trn-13-set="error">error</button>
</div>
<pre class="trn-13__snippet">.trn-13__chip { transition: color 260ms ease, border-color 260ms ease, background-color 260ms ease; }
[data-state="loading"] .trn-13__chip { color: var(--amber); border-color: var(--amber); }
[data-state="success"] .trn-13__chip { color: var(--green); border-color: var(--green); }
[data-state="error"] .trn-13__chip { color: var(--red); border-color: var(--red); }</pre>
</div>
</section><section class="trn-13" aria-labelledby="trn-13-heading">
<div class="trn-13__stage">
<div class="trn-13__theme">
<input class="trn-13__themecb" type="checkbox" id="trn-13-theme">
<label class="trn-13__themebtn" for="trn-13-theme">
<span class="trn-13__themeico" aria-hidden="true">
<svg class="trn-13__sun" viewBox="0 0 20 20" width="15" height="15"><circle cx="10" cy="10" r="3.9" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 1.7v2.1M10 16.2v2.1M1.7 10h2.1M16.2 10h2.1M4.2 4.2l1.5 1.5M14.3 14.3l1.5 1.5M15.8 4.2l-1.5 1.5M5.7 14.3l-1.5 1.5" stroke="currentColor" stroke-width="1.4"/></svg>
<svg class="trn-13__moon" viewBox="0 0 20 20" width="15" height="15"><path d="M15.4 12.7A6.7 6.7 0 0 1 7.3 4.6a6.8 6.8 0 1 0 8.1 8.1Z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</span>
<span>Theme</span>
</label>
</div>
<header class="trn-13__head">
<p class="trn-13__kicker">[data-state]</p>
<h2 class="trn-13__heading" id="trn-13-heading">One attribute, four states, zero impossible combinations</h2>
<p class="trn-13__sub">Every transition below is a change of one attribute value. No classes are added or removed.</p>
</header>
<div class="trn-13__machine" id="trn-13-machine" data-state="idle">
<div class="trn-13__chip">
<span class="trn-13__glyph" aria-hidden="true">
<svg class="trn-13__ico trn-13__ico--idle" viewBox="0 0 20 20" width="18" height="18"><circle cx="10" cy="10" r="7.2" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
<svg class="trn-13__ico trn-13__ico--load" viewBox="0 0 20 20" width="18" height="18"><path d="M10 2.8a7.2 7.2 0 1 1-7.1 8.4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<svg class="trn-13__ico trn-13__ico--ok" viewBox="0 0 20 20" width="18" height="18"><path d="m4.6 10.4 3.3 3.3 7.5-7.6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
<svg class="trn-13__ico trn-13__ico--err" viewBox="0 0 20 20" width="18" height="18"><path d="M5.4 5.4l9.2 9.2M14.6 5.4l-9.2 9.2" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
</span>
<span class="trn-13__label" id="trn-13-label" role="status">Ready to deploy</span>
<code class="trn-13__value" id="trn-13-value">data-state="idle"</code>
</div>
<div class="trn-13__meter"><span class="trn-13__fill"></span></div>
</div>
<div class="trn-13__controls">
<button class="trn-13__btn" type="button" data-trn-13-set="idle">idle</button>
<button class="trn-13__btn" type="button" data-trn-13-set="loading">loading</button>
<button class="trn-13__btn" type="button" data-trn-13-set="success">success</button>
<button class="trn-13__btn" type="button" data-trn-13-set="error">error</button>
</div>
<pre class="trn-13__snippet">.trn-13__chip { transition: color 260ms ease, border-color 260ms ease, background-color 260ms ease; }
[data-state="loading"] .trn-13__chip { color: var(--amber); border-color: var(--amber); }
[data-state="success"] .trn-13__chip { color: var(--green); border-color: var(--green); }
[data-state="error"] .trn-13__chip { color: var(--red); border-color: var(--red); }</pre>
</div>
</section>.trn-13 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--page: #05080a;
--grid: #0d1418;
--ink: #d6f5df;
--ink: oklch(0.93 0.05 155);
--muted: #6d8878;
--muted: oklch(0.58 0.03 158);
--rule: #16211c;
--green: #3ff58a;
--green: oklch(0.87 0.2 150);
--amber: #ffc857;
--amber: oklch(0.85 0.14 82);
--red: #ff6b6b;
--red: oklch(0.71 0.18 22);
--radius: 4px;
--font-display: ui-monospace, "IBM Plex Mono", "JetBrains Mono", Menlo, monospace;
background: linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 100% 22px, linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 22px 100%, var(--page);
color: var(--ink);
font-family: var(--font-display);
}
.trn-13 *,
.trn-13 *::before,
.trn-13 *::after {
box-sizing: border-box;
}
.trn-13__stage {
display: grid;
place-items: start center;
max-inline-size: 100%;
padding: clamp(22px, 5vw, 52px) 14px;
}
.trn-13__head {
inline-size: 100%;
max-inline-size: 50rem;
min-inline-size: 0;
margin: 0 0 20px;
}
.trn-13__kicker {
margin: 0 0 8px;
font: 400 11px/1 var(--font-display);
letter-spacing: 0.14em;
color: var(--green);
}
.trn-13__heading {
margin: 0 0 8px;
font: 400 clamp(20px, 4vw, 29px)/1.2 var(--font-display);
letter-spacing: -0.01em;
text-wrap: balance;
}
.trn-13__sub {
margin: 0;
max-inline-size: 54ch;
font: 400 13px/1.6 system-ui, sans-serif;
color: var(--muted);
text-wrap: pretty;
}
.trn-13__machine {
inline-size: 100%;
max-inline-size: 50rem;
min-inline-size: 0;
}
.trn-13__chip {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
min-inline-size: 0;
padding: 10px 12px;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: rgba(63, 245, 138, 0.04);
color: var(--muted);
transition: color 260ms ease, border-color 260ms ease, background-color 260ms ease;
}
.trn-13__glyph {
flex: none;
display: grid;
place-items: center;
inline-size: 20px;
block-size: 20px;
}
.trn-13__ico {
grid-area: 1 / 1;
opacity: 0;
transition: opacity 200ms ease;
}
.trn-13__label {
font: 400 13px/1 var(--font-display);
color: var(--ink);
}
.trn-13__value {
margin-inline-start: auto;
font: 400 11.5px/1 var(--font-display);
color: var(--muted);
}
.trn-13__meter {
margin-block-start: 10px;
block-size: 4px;
background: var(--rule);
border-radius: 2px;
overflow: hidden;
}
.trn-13__fill {
display: block;
block-size: 100%;
inline-size: 100%;
background: var(--muted);
transform: scaleX(0);
transform-origin: left;
transition: transform 420ms cubic-bezier(.3, .8, .3, 1), background-color 260ms ease;
}
.trn-13__machine[data-state="idle"] .trn-13__ico--idle {
opacity: 1;
}
.trn-13__machine[data-state="loading"] .trn-13__ico--load {
opacity: 1;
animation: trn-13-spin 900ms linear infinite;
}
.trn-13__machine[data-state="success"] .trn-13__ico--ok {
opacity: 1;
}
.trn-13__machine[data-state="error"] .trn-13__ico--err {
opacity: 1;
}
.trn-13__machine[data-state="loading"] .trn-13__chip {
color: var(--amber);
border-color: var(--amber);
background: rgba(255, 200, 87, 0.06);
}
.trn-13__machine[data-state="success"] .trn-13__chip {
color: var(--green);
border-color: var(--green);
background: rgba(63, 245, 138, 0.07);
}
.trn-13__machine[data-state="error"] .trn-13__chip {
color: var(--red);
border-color: var(--red);
background: rgba(255, 107, 107, 0.07);
}
.trn-13__machine[data-state="loading"] .trn-13__fill {
transform: scaleX(0.62);
background: var(--amber);
}
.trn-13__machine[data-state="success"] .trn-13__fill {
transform: scaleX(1);
background: var(--green);
}
.trn-13__machine[data-state="error"] .trn-13__fill {
transform: scaleX(0.34);
background: var(--red);
}
@keyframes trn-13-spin {
to {
transform: rotate(360deg);
}
}
.trn-13__controls {
inline-size: 100%;
max-inline-size: 50rem;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-block-start: 16px;
}
.trn-13__btn {
min-height: 44px;
padding: 0 14px;
background: transparent;
color: var(--ink);
border: 1px solid var(--rule);
border-radius: var(--radius);
cursor: pointer;
font: 400 12.5px/1 var(--font-display);
transition: border-color 180ms ease, color 180ms ease, background-color 180ms ease;
}
.trn-13__btn:hover {
border-color: var(--green);
color: var(--green);
background: rgba(63, 245, 138, 0.06);
}
.trn-13__btn:focus-visible {
outline: 2px solid var(--green);
outline-offset: 2px;
}
.trn-13__btn[aria-pressed="true"] {
border-color: var(--green);
color: var(--page);
background: var(--green);
}
.trn-13__snippet {
inline-size: 100%;
max-inline-size: 50rem;
margin: 18px 0 0;
padding: 12px;
background: rgba(63, 245, 138, 0.04);
border: 1px solid var(--rule);
border-radius: var(--radius);
font: 400 11px/1.65 var(--font-display);
color: var(--muted);
overflow-x: auto;
}
@media (prefers-reduced-motion: reduce) {
.trn-13__chip {
transition-duration: 140ms;
}
.trn-13__fill {
transition: background-color 140ms ease;
transform: scaleX(1);
}
.trn-13__machine[data-state="loading"] .trn-13__fill,
.trn-13__machine[data-state="success"] .trn-13__fill,
.trn-13__machine[data-state="error"] .trn-13__fill {
transform: scaleX(1);
}
.trn-13__machine[data-state="loading"] .trn-13__ico--load {
animation: none;
}
}
@media (prefers-color-scheme: light) {
.trn-13 {
--page: #f4f9f5;
--grid: #e2ece5;
--ink: #0c1a12;
--muted: #4c6355;
--rule: #cddbd2;
--green: #10763f;
--amber: #8a5b06;
--red: #b02a2a;
}
.trn-13__btn[aria-pressed="true"] {
color: #f4f9f5;
}
}
[data-theme="dark"] .trn-13 {
--page: #05080a;
--grid: #0d1418;
--ink: #d6f5df;
--muted: #6d8878;
--rule: #16211c;
--green: #3ff58a;
--amber: #ffc857;
--red: #ff6b6b;
}
[data-theme="dark"] .trn-13__btn[aria-pressed="true"] {
color: #05080a;
}
.trn-13__theme {
position: relative;
inline-size: 100%;
min-inline-size: 0;
display: flex;
justify-content: flex-end;
margin: 0 0 14px;
}
.trn-13__themecb {
position: absolute;
inline-size: 1px;
block-size: 1px;
clip-path: inset(50%);
overflow: hidden;
}
.trn-13__themebtn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 14px;
border: 1px solid currentColor;
border-radius: 999px;
cursor: pointer;
color: var(--muted);
font: 500 11px/1 ui-monospace, Menlo, monospace;
letter-spacing: 0.1em;
text-transform: uppercase;
transition: color 200ms ease, opacity 200ms ease;
}
.trn-13__themebtn:hover {
color: var(--ink);
}
.trn-13__themecb:focus-visible + .trn-13__themebtn {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.trn-13__themeico {
display: grid;
place-items: center;
inline-size: 15px;
block-size: 15px;
}
.trn-13__themeico svg {
grid-area: 1 / 1;
transition: opacity 200ms ease;
}
.trn-13__sun {
opacity: 0.34;
}
.trn-13__moon {
opacity: 1;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__sun {
opacity: 1;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__moon {
opacity: 0.34;
}
@media (prefers-color-scheme: dark) {
.trn-13:has(#trn-13-theme:checked) {
--page: #f4f9f5;
--grid: #e2ece5;
--ink: #0c1a12;
--muted: #4c6355;
--rule: #cddbd2;
--green: #10763f;
--amber: #8a5b06;
--red: #b02a2a;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__btn[aria-pressed="true"] {
color: #f4f9f5;
}
}
@media (prefers-color-scheme: light) {
.trn-13:has(#trn-13-theme:checked) {
--page: #05080a;
--grid: #0d1418;
--ink: #d6f5df;
--ink: oklch(0.93 0.05 155);
--muted: #6d8878;
--muted: oklch(0.58 0.03 158);
--rule: #16211c;
--green: #3ff58a;
--green: oklch(0.87 0.2 150);
--amber: #ffc857;
--amber: oklch(0.85 0.14 82);
--red: #ff6b6b;
--red: oklch(0.71 0.18 22);
--radius: 4px;
--font-display: ui-monospace, "IBM Plex Mono", "JetBrains Mono", Menlo, monospace;
background: linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 100% 22px, linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 22px 100%, var(--page);
}
.trn-13:has(#trn-13-theme:checked) .trn-13__btn[aria-pressed="true"] {
border-color: var(--green);
color: var(--page);
background: var(--green);
}
.trn-13:has(#trn-13-theme:checked) .trn-13__sun {
opacity: 0.34;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__moon {
opacity: 1;
}
}.trn-13 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--page: #05080a;
--grid: #0d1418;
--ink: #d6f5df;
--ink: oklch(0.93 0.05 155);
--muted: #6d8878;
--muted: oklch(0.58 0.03 158);
--rule: #16211c;
--green: #3ff58a;
--green: oklch(0.87 0.2 150);
--amber: #ffc857;
--amber: oklch(0.85 0.14 82);
--red: #ff6b6b;
--red: oklch(0.71 0.18 22);
--radius: 4px;
--font-display: ui-monospace, "IBM Plex Mono", "JetBrains Mono", Menlo, monospace;
background: linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 100% 22px, linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 22px 100%, var(--page);
color: var(--ink);
font-family: var(--font-display);
}
.trn-13 *,
.trn-13 *::before,
.trn-13 *::after {
box-sizing: border-box;
}
.trn-13__stage {
display: grid;
place-items: start center;
max-inline-size: 100%;
padding: clamp(22px, 5vw, 52px) 14px;
}
.trn-13__head {
inline-size: 100%;
max-inline-size: 50rem;
min-inline-size: 0;
margin: 0 0 20px;
}
.trn-13__kicker {
margin: 0 0 8px;
font: 400 11px/1 var(--font-display);
letter-spacing: 0.14em;
color: var(--green);
}
.trn-13__heading {
margin: 0 0 8px;
font: 400 clamp(20px, 4vw, 29px)/1.2 var(--font-display);
letter-spacing: -0.01em;
text-wrap: balance;
}
.trn-13__sub {
margin: 0;
max-inline-size: 54ch;
font: 400 13px/1.6 system-ui, sans-serif;
color: var(--muted);
text-wrap: pretty;
}
.trn-13__machine {
inline-size: 100%;
max-inline-size: 50rem;
min-inline-size: 0;
}
.trn-13__chip {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
min-inline-size: 0;
padding: 10px 12px;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: rgba(63, 245, 138, 0.04);
color: var(--muted);
transition: color 260ms ease, border-color 260ms ease, background-color 260ms ease;
}
.trn-13__glyph {
flex: none;
display: grid;
place-items: center;
inline-size: 20px;
block-size: 20px;
}
.trn-13__ico {
grid-area: 1 / 1;
opacity: 0;
transition: opacity 200ms ease;
}
.trn-13__label {
font: 400 13px/1 var(--font-display);
color: var(--ink);
}
.trn-13__value {
margin-inline-start: auto;
font: 400 11.5px/1 var(--font-display);
color: var(--muted);
}
.trn-13__meter {
margin-block-start: 10px;
block-size: 4px;
background: var(--rule);
border-radius: 2px;
overflow: hidden;
}
.trn-13__fill {
display: block;
block-size: 100%;
inline-size: 100%;
background: var(--muted);
transform: scaleX(0);
transform-origin: left;
transition: transform 420ms cubic-bezier(.3, .8, .3, 1), background-color 260ms ease;
}
.trn-13__machine[data-state="idle"] .trn-13__ico--idle {
opacity: 1;
}
.trn-13__machine[data-state="loading"] .trn-13__ico--load {
opacity: 1;
animation: trn-13-spin 900ms linear infinite;
}
.trn-13__machine[data-state="success"] .trn-13__ico--ok {
opacity: 1;
}
.trn-13__machine[data-state="error"] .trn-13__ico--err {
opacity: 1;
}
.trn-13__machine[data-state="loading"] .trn-13__chip {
color: var(--amber);
border-color: var(--amber);
background: rgba(255, 200, 87, 0.06);
}
.trn-13__machine[data-state="success"] .trn-13__chip {
color: var(--green);
border-color: var(--green);
background: rgba(63, 245, 138, 0.07);
}
.trn-13__machine[data-state="error"] .trn-13__chip {
color: var(--red);
border-color: var(--red);
background: rgba(255, 107, 107, 0.07);
}
.trn-13__machine[data-state="loading"] .trn-13__fill {
transform: scaleX(0.62);
background: var(--amber);
}
.trn-13__machine[data-state="success"] .trn-13__fill {
transform: scaleX(1);
background: var(--green);
}
.trn-13__machine[data-state="error"] .trn-13__fill {
transform: scaleX(0.34);
background: var(--red);
}
@keyframes trn-13-spin {
to {
transform: rotate(360deg);
}
}
.trn-13__controls {
inline-size: 100%;
max-inline-size: 50rem;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-block-start: 16px;
}
.trn-13__btn {
min-height: 44px;
padding: 0 14px;
background: transparent;
color: var(--ink);
border: 1px solid var(--rule);
border-radius: var(--radius);
cursor: pointer;
font: 400 12.5px/1 var(--font-display);
transition: border-color 180ms ease, color 180ms ease, background-color 180ms ease;
}
.trn-13__btn:hover {
border-color: var(--green);
color: var(--green);
background: rgba(63, 245, 138, 0.06);
}
.trn-13__btn:focus-visible {
outline: 2px solid var(--green);
outline-offset: 2px;
}
.trn-13__btn[aria-pressed="true"] {
border-color: var(--green);
color: var(--page);
background: var(--green);
}
.trn-13__snippet {
inline-size: 100%;
max-inline-size: 50rem;
margin: 18px 0 0;
padding: 12px;
background: rgba(63, 245, 138, 0.04);
border: 1px solid var(--rule);
border-radius: var(--radius);
font: 400 11px/1.65 var(--font-display);
color: var(--muted);
overflow-x: auto;
}
@media (prefers-reduced-motion: reduce) {
.trn-13__chip {
transition-duration: 140ms;
}
.trn-13__fill {
transition: background-color 140ms ease;
transform: scaleX(1);
}
.trn-13__machine[data-state="loading"] .trn-13__fill,
.trn-13__machine[data-state="success"] .trn-13__fill,
.trn-13__machine[data-state="error"] .trn-13__fill {
transform: scaleX(1);
}
.trn-13__machine[data-state="loading"] .trn-13__ico--load {
animation: none;
}
}
@media (prefers-color-scheme: light) {
.trn-13 {
--page: #f4f9f5;
--grid: #e2ece5;
--ink: #0c1a12;
--muted: #4c6355;
--rule: #cddbd2;
--green: #10763f;
--amber: #8a5b06;
--red: #b02a2a;
}
.trn-13__btn[aria-pressed="true"] {
color: #f4f9f5;
}
}
[data-theme="dark"] .trn-13 {
--page: #05080a;
--grid: #0d1418;
--ink: #d6f5df;
--muted: #6d8878;
--rule: #16211c;
--green: #3ff58a;
--amber: #ffc857;
--red: #ff6b6b;
}
[data-theme="dark"] .trn-13__btn[aria-pressed="true"] {
color: #05080a;
}
.trn-13__theme {
position: relative;
inline-size: 100%;
min-inline-size: 0;
display: flex;
justify-content: flex-end;
margin: 0 0 14px;
}
.trn-13__themecb {
position: absolute;
inline-size: 1px;
block-size: 1px;
clip-path: inset(50%);
overflow: hidden;
}
.trn-13__themebtn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 14px;
border: 1px solid currentColor;
border-radius: 999px;
cursor: pointer;
color: var(--muted);
font: 500 11px/1 ui-monospace, Menlo, monospace;
letter-spacing: 0.1em;
text-transform: uppercase;
transition: color 200ms ease, opacity 200ms ease;
}
.trn-13__themebtn:hover {
color: var(--ink);
}
.trn-13__themecb:focus-visible + .trn-13__themebtn {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.trn-13__themeico {
display: grid;
place-items: center;
inline-size: 15px;
block-size: 15px;
}
.trn-13__themeico svg {
grid-area: 1 / 1;
transition: opacity 200ms ease;
}
.trn-13__sun {
opacity: 0.34;
}
.trn-13__moon {
opacity: 1;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__sun {
opacity: 1;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__moon {
opacity: 0.34;
}
@media (prefers-color-scheme: dark) {
.trn-13:has(#trn-13-theme:checked) {
--page: #f4f9f5;
--grid: #e2ece5;
--ink: #0c1a12;
--muted: #4c6355;
--rule: #cddbd2;
--green: #10763f;
--amber: #8a5b06;
--red: #b02a2a;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__btn[aria-pressed="true"] {
color: #f4f9f5;
}
}
@media (prefers-color-scheme: light) {
.trn-13:has(#trn-13-theme:checked) {
--page: #05080a;
--grid: #0d1418;
--ink: #d6f5df;
--ink: oklch(0.93 0.05 155);
--muted: #6d8878;
--muted: oklch(0.58 0.03 158);
--rule: #16211c;
--green: #3ff58a;
--green: oklch(0.87 0.2 150);
--amber: #ffc857;
--amber: oklch(0.85 0.14 82);
--red: #ff6b6b;
--red: oklch(0.71 0.18 22);
--radius: 4px;
--font-display: ui-monospace, "IBM Plex Mono", "JetBrains Mono", Menlo, monospace;
background: linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 100% 22px, linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 22px 100%, var(--page);
}
.trn-13:has(#trn-13-theme:checked) .trn-13__btn[aria-pressed="true"] {
border-color: var(--green);
color: var(--page);
background: var(--green);
}
.trn-13:has(#trn-13-theme:checked) .trn-13__sun {
opacity: 0.34;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__moon {
opacity: 1;
}
}const trn13 = document.querySelector('.trn-13');
if (trn13) {
const machine = trn13.querySelector('#trn-13-machine');
const label = trn13.querySelector('#trn-13-label');
const value = trn13.querySelector('#trn-13-value');
const copy = { idle: 'Ready to deploy', loading: 'Deploying to production…', success: 'Deployed — build 4192 live', error: 'Failed — migration 0041 rejected' };
const buttons = [...trn13.querySelectorAll('[data-trn-13-set]')];
const apply = (state) => {
machine.setAttribute('data-state', state);
label.textContent = copy[state];
value.textContent = 'data-state="' + state + '"';
for (const b of buttons) b.setAttribute('aria-pressed', String(b.getAttribute('data-trn-13-set') === state));
};
for (const b of buttons) b.addEventListener('click', () => apply(b.getAttribute('data-trn-13-set')));
apply('idle');
}const trn13 = document.querySelector('.trn-13');
if (trn13) {
const machine = trn13.querySelector('#trn-13-machine');
const label = trn13.querySelector('#trn-13-label');
const value = trn13.querySelector('#trn-13-value');
const copy = { idle: 'Ready to deploy', loading: 'Deploying to production…', success: 'Deployed — build 4192 live', error: 'Failed — migration 0041 rejected' };
const buttons = [...trn13.querySelectorAll('[data-trn-13-set]')];
const apply = (state) => {
machine.setAttribute('data-state', state);
label.textContent = copy[state];
value.textContent = 'data-state="' + state + '"';
for (const b of buttons) b.setAttribute('aria-pressed', String(b.getAttribute('data-trn-13-set') === state));
};
for (const b of buttons) b.addEventListener('click', () => apply(b.getAttribute('data-trn-13-set')));
apply('idle');
}Here's a working CSS Transition from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Data Attribute State Machine Transitions
Source: https://codefronts.com/motion/css-transition-designs/ripple-effect-on-click/
One element moving through idle, loading, success and error, driven entirely by a single data-state attribute, with every visual difference expressed as a transition between attribute selectors. Argues against class juggling: one attribute is one source of truth and cannot land in an impossible combined state the way four independent booleans can.
## HTML
```html
<section class="trn-13" aria-labelledby="trn-13-heading">
<div class="trn-13__stage">
<div class="trn-13__theme">
<input class="trn-13__themecb" type="checkbox" id="trn-13-theme">
<label class="trn-13__themebtn" for="trn-13-theme">
<span class="trn-13__themeico" aria-hidden="true">
<svg class="trn-13__sun" viewBox="0 0 20 20" width="15" height="15"><circle cx="10" cy="10" r="3.9" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 1.7v2.1M10 16.2v2.1M1.7 10h2.1M16.2 10h2.1M4.2 4.2l1.5 1.5M14.3 14.3l1.5 1.5M15.8 4.2l-1.5 1.5M5.7 14.3l-1.5 1.5" stroke="currentColor" stroke-width="1.4"/></svg>
<svg class="trn-13__moon" viewBox="0 0 20 20" width="15" height="15"><path d="M15.4 12.7A6.7 6.7 0 0 1 7.3 4.6a6.8 6.8 0 1 0 8.1 8.1Z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</span>
<span>Theme</span>
</label>
</div>
<header class="trn-13__head">
<p class="trn-13__kicker">[data-state]</p>
<h2 class="trn-13__heading" id="trn-13-heading">One attribute, four states, zero impossible combinations</h2>
<p class="trn-13__sub">Every transition below is a change of one attribute value. No classes are added or removed.</p>
</header>
<div class="trn-13__machine" id="trn-13-machine" data-state="idle">
<div class="trn-13__chip">
<span class="trn-13__glyph" aria-hidden="true">
<svg class="trn-13__ico trn-13__ico--idle" viewBox="0 0 20 20" width="18" height="18"><circle cx="10" cy="10" r="7.2" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
<svg class="trn-13__ico trn-13__ico--load" viewBox="0 0 20 20" width="18" height="18"><path d="M10 2.8a7.2 7.2 0 1 1-7.1 8.4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<svg class="trn-13__ico trn-13__ico--ok" viewBox="0 0 20 20" width="18" height="18"><path d="m4.6 10.4 3.3 3.3 7.5-7.6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
<svg class="trn-13__ico trn-13__ico--err" viewBox="0 0 20 20" width="18" height="18"><path d="M5.4 5.4l9.2 9.2M14.6 5.4l-9.2 9.2" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
</span>
<span class="trn-13__label" id="trn-13-label" role="status">Ready to deploy</span>
<code class="trn-13__value" id="trn-13-value">data-state="idle"</code>
</div>
<div class="trn-13__meter"><span class="trn-13__fill"></span></div>
</div>
<div class="trn-13__controls">
<button class="trn-13__btn" type="button" data-trn-13-set="idle">idle</button>
<button class="trn-13__btn" type="button" data-trn-13-set="loading">loading</button>
<button class="trn-13__btn" type="button" data-trn-13-set="success">success</button>
<button class="trn-13__btn" type="button" data-trn-13-set="error">error</button>
</div>
<pre class="trn-13__snippet">.trn-13__chip { transition: color 260ms ease, border-color 260ms ease, background-color 260ms ease; }
[data-state="loading"] .trn-13__chip { color: var(--amber); border-color: var(--amber); }
[data-state="success"] .trn-13__chip { color: var(--green); border-color: var(--green); }
[data-state="error"] .trn-13__chip { color: var(--red); border-color: var(--red); }</pre>
</div>
</section>
```
## CSS
```css
.trn-13 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--page: #05080a;
--grid: #0d1418;
--ink: #d6f5df;
--ink: oklch(0.93 0.05 155);
--muted: #6d8878;
--muted: oklch(0.58 0.03 158);
--rule: #16211c;
--green: #3ff58a;
--green: oklch(0.87 0.2 150);
--amber: #ffc857;
--amber: oklch(0.85 0.14 82);
--red: #ff6b6b;
--red: oklch(0.71 0.18 22);
--radius: 4px;
--font-display: ui-monospace, "IBM Plex Mono", "JetBrains Mono", Menlo, monospace;
background: linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 100% 22px, linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 22px 100%, var(--page);
color: var(--ink);
font-family: var(--font-display);
}
.trn-13 *,
.trn-13 *::before,
.trn-13 *::after {
box-sizing: border-box;
}
.trn-13__stage {
display: grid;
place-items: start center;
max-inline-size: 100%;
padding: clamp(22px, 5vw, 52px) 14px;
}
.trn-13__head {
inline-size: 100%;
max-inline-size: 50rem;
min-inline-size: 0;
margin: 0 0 20px;
}
.trn-13__kicker {
margin: 0 0 8px;
font: 400 11px/1 var(--font-display);
letter-spacing: 0.14em;
color: var(--green);
}
.trn-13__heading {
margin: 0 0 8px;
font: 400 clamp(20px, 4vw, 29px)/1.2 var(--font-display);
letter-spacing: -0.01em;
text-wrap: balance;
}
.trn-13__sub {
margin: 0;
max-inline-size: 54ch;
font: 400 13px/1.6 system-ui, sans-serif;
color: var(--muted);
text-wrap: pretty;
}
.trn-13__machine {
inline-size: 100%;
max-inline-size: 50rem;
min-inline-size: 0;
}
.trn-13__chip {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
min-inline-size: 0;
padding: 10px 12px;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: rgba(63, 245, 138, 0.04);
color: var(--muted);
transition: color 260ms ease, border-color 260ms ease, background-color 260ms ease;
}
.trn-13__glyph {
flex: none;
display: grid;
place-items: center;
inline-size: 20px;
block-size: 20px;
}
.trn-13__ico {
grid-area: 1 / 1;
opacity: 0;
transition: opacity 200ms ease;
}
.trn-13__label {
font: 400 13px/1 var(--font-display);
color: var(--ink);
}
.trn-13__value {
margin-inline-start: auto;
font: 400 11.5px/1 var(--font-display);
color: var(--muted);
}
.trn-13__meter {
margin-block-start: 10px;
block-size: 4px;
background: var(--rule);
border-radius: 2px;
overflow: hidden;
}
.trn-13__fill {
display: block;
block-size: 100%;
inline-size: 100%;
background: var(--muted);
transform: scaleX(0);
transform-origin: left;
transition: transform 420ms cubic-bezier(.3, .8, .3, 1), background-color 260ms ease;
}
.trn-13__machine[data-state="idle"] .trn-13__ico--idle {
opacity: 1;
}
.trn-13__machine[data-state="loading"] .trn-13__ico--load {
opacity: 1;
animation: trn-13-spin 900ms linear infinite;
}
.trn-13__machine[data-state="success"] .trn-13__ico--ok {
opacity: 1;
}
.trn-13__machine[data-state="error"] .trn-13__ico--err {
opacity: 1;
}
.trn-13__machine[data-state="loading"] .trn-13__chip {
color: var(--amber);
border-color: var(--amber);
background: rgba(255, 200, 87, 0.06);
}
.trn-13__machine[data-state="success"] .trn-13__chip {
color: var(--green);
border-color: var(--green);
background: rgba(63, 245, 138, 0.07);
}
.trn-13__machine[data-state="error"] .trn-13__chip {
color: var(--red);
border-color: var(--red);
background: rgba(255, 107, 107, 0.07);
}
.trn-13__machine[data-state="loading"] .trn-13__fill {
transform: scaleX(0.62);
background: var(--amber);
}
.trn-13__machine[data-state="success"] .trn-13__fill {
transform: scaleX(1);
background: var(--green);
}
.trn-13__machine[data-state="error"] .trn-13__fill {
transform: scaleX(0.34);
background: var(--red);
}
@keyframes trn-13-spin {
to {
transform: rotate(360deg);
}
}
.trn-13__controls {
inline-size: 100%;
max-inline-size: 50rem;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-block-start: 16px;
}
.trn-13__btn {
min-height: 44px;
padding: 0 14px;
background: transparent;
color: var(--ink);
border: 1px solid var(--rule);
border-radius: var(--radius);
cursor: pointer;
font: 400 12.5px/1 var(--font-display);
transition: border-color 180ms ease, color 180ms ease, background-color 180ms ease;
}
.trn-13__btn:hover {
border-color: var(--green);
color: var(--green);
background: rgba(63, 245, 138, 0.06);
}
.trn-13__btn:focus-visible {
outline: 2px solid var(--green);
outline-offset: 2px;
}
.trn-13__btn[aria-pressed="true"] {
border-color: var(--green);
color: var(--page);
background: var(--green);
}
.trn-13__snippet {
inline-size: 100%;
max-inline-size: 50rem;
margin: 18px 0 0;
padding: 12px;
background: rgba(63, 245, 138, 0.04);
border: 1px solid var(--rule);
border-radius: var(--radius);
font: 400 11px/1.65 var(--font-display);
color: var(--muted);
overflow-x: auto;
}
@media (prefers-reduced-motion: reduce) {
.trn-13__chip {
transition-duration: 140ms;
}
.trn-13__fill {
transition: background-color 140ms ease;
transform: scaleX(1);
}
.trn-13__machine[data-state="loading"] .trn-13__fill,
.trn-13__machine[data-state="success"] .trn-13__fill,
.trn-13__machine[data-state="error"] .trn-13__fill {
transform: scaleX(1);
}
.trn-13__machine[data-state="loading"] .trn-13__ico--load {
animation: none;
}
}
@media (prefers-color-scheme: light) {
.trn-13 {
--page: #f4f9f5;
--grid: #e2ece5;
--ink: #0c1a12;
--muted: #4c6355;
--rule: #cddbd2;
--green: #10763f;
--amber: #8a5b06;
--red: #b02a2a;
}
.trn-13__btn[aria-pressed="true"] {
color: #f4f9f5;
}
}
[data-theme="dark"] .trn-13 {
--page: #05080a;
--grid: #0d1418;
--ink: #d6f5df;
--muted: #6d8878;
--rule: #16211c;
--green: #3ff58a;
--amber: #ffc857;
--red: #ff6b6b;
}
[data-theme="dark"] .trn-13__btn[aria-pressed="true"] {
color: #05080a;
}
.trn-13__theme {
position: relative;
inline-size: 100%;
min-inline-size: 0;
display: flex;
justify-content: flex-end;
margin: 0 0 14px;
}
.trn-13__themecb {
position: absolute;
inline-size: 1px;
block-size: 1px;
clip-path: inset(50%);
overflow: hidden;
}
.trn-13__themebtn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 14px;
border: 1px solid currentColor;
border-radius: 999px;
cursor: pointer;
color: var(--muted);
font: 500 11px/1 ui-monospace, Menlo, monospace;
letter-spacing: 0.1em;
text-transform: uppercase;
transition: color 200ms ease, opacity 200ms ease;
}
.trn-13__themebtn:hover {
color: var(--ink);
}
.trn-13__themecb:focus-visible + .trn-13__themebtn {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.trn-13__themeico {
display: grid;
place-items: center;
inline-size: 15px;
block-size: 15px;
}
.trn-13__themeico svg {
grid-area: 1 / 1;
transition: opacity 200ms ease;
}
.trn-13__sun {
opacity: 0.34;
}
.trn-13__moon {
opacity: 1;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__sun {
opacity: 1;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__moon {
opacity: 0.34;
}
@media (prefers-color-scheme: dark) {
.trn-13:has(#trn-13-theme:checked) {
--page: #f4f9f5;
--grid: #e2ece5;
--ink: #0c1a12;
--muted: #4c6355;
--rule: #cddbd2;
--green: #10763f;
--amber: #8a5b06;
--red: #b02a2a;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__btn[aria-pressed="true"] {
color: #f4f9f5;
}
}
@media (prefers-color-scheme: light) {
.trn-13:has(#trn-13-theme:checked) {
--page: #05080a;
--grid: #0d1418;
--ink: #d6f5df;
--ink: oklch(0.93 0.05 155);
--muted: #6d8878;
--muted: oklch(0.58 0.03 158);
--rule: #16211c;
--green: #3ff58a;
--green: oklch(0.87 0.2 150);
--amber: #ffc857;
--amber: oklch(0.85 0.14 82);
--red: #ff6b6b;
--red: oklch(0.71 0.18 22);
--radius: 4px;
--font-display: ui-monospace, "IBM Plex Mono", "JetBrains Mono", Menlo, monospace;
background: linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 100% 22px, linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 22px 100%, var(--page);
}
.trn-13:has(#trn-13-theme:checked) .trn-13__btn[aria-pressed="true"] {
border-color: var(--green);
color: var(--page);
background: var(--green);
}
.trn-13:has(#trn-13-theme:checked) .trn-13__sun {
opacity: 0.34;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__moon {
opacity: 1;
}
}
```
## JavaScript
```js
const trn13 = document.querySelector('.trn-13');
if (trn13) {
const machine = trn13.querySelector('#trn-13-machine');
const label = trn13.querySelector('#trn-13-label');
const value = trn13.querySelector('#trn-13-value');
const copy = { idle: 'Ready to deploy', loading: 'Deploying to production…', success: 'Deployed — build 4192 live', error: 'Failed — migration 0041 rejected' };
const buttons = [...trn13.querySelectorAll('[data-trn-13-set]')];
const apply = (state) => {
machine.setAttribute('data-state', state);
label.textContent = copy[state];
value.textContent = 'data-state="' + state + '"';
for (const b of buttons) b.setAttribute('aria-pressed', String(b.getAttribute('data-trn-13-set') === state));
};
for (const b of buttons) b.addEventListener('click', () => apply(b.getAttribute('data-trn-13-set')));
apply('idle');
}
```Here's a working CSS Transition from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Data Attribute State Machine Transitions
Source: https://codefronts.com/motion/css-transition-designs/ripple-effect-on-click/
One element moving through idle, loading, success and error, driven entirely by a single data-state attribute, with every visual difference expressed as a transition between attribute selectors. Argues against class juggling: one attribute is one source of truth and cannot land in an impossible combined state the way four independent booleans can.
## HTML
```html
<section class="trn-13" aria-labelledby="trn-13-heading">
<div class="trn-13__stage">
<div class="trn-13__theme">
<input class="trn-13__themecb" type="checkbox" id="trn-13-theme">
<label class="trn-13__themebtn" for="trn-13-theme">
<span class="trn-13__themeico" aria-hidden="true">
<svg class="trn-13__sun" viewBox="0 0 20 20" width="15" height="15"><circle cx="10" cy="10" r="3.9" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 1.7v2.1M10 16.2v2.1M1.7 10h2.1M16.2 10h2.1M4.2 4.2l1.5 1.5M14.3 14.3l1.5 1.5M15.8 4.2l-1.5 1.5M5.7 14.3l-1.5 1.5" stroke="currentColor" stroke-width="1.4"/></svg>
<svg class="trn-13__moon" viewBox="0 0 20 20" width="15" height="15"><path d="M15.4 12.7A6.7 6.7 0 0 1 7.3 4.6a6.8 6.8 0 1 0 8.1 8.1Z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</span>
<span>Theme</span>
</label>
</div>
<header class="trn-13__head">
<p class="trn-13__kicker">[data-state]</p>
<h2 class="trn-13__heading" id="trn-13-heading">One attribute, four states, zero impossible combinations</h2>
<p class="trn-13__sub">Every transition below is a change of one attribute value. No classes are added or removed.</p>
</header>
<div class="trn-13__machine" id="trn-13-machine" data-state="idle">
<div class="trn-13__chip">
<span class="trn-13__glyph" aria-hidden="true">
<svg class="trn-13__ico trn-13__ico--idle" viewBox="0 0 20 20" width="18" height="18"><circle cx="10" cy="10" r="7.2" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
<svg class="trn-13__ico trn-13__ico--load" viewBox="0 0 20 20" width="18" height="18"><path d="M10 2.8a7.2 7.2 0 1 1-7.1 8.4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<svg class="trn-13__ico trn-13__ico--ok" viewBox="0 0 20 20" width="18" height="18"><path d="m4.6 10.4 3.3 3.3 7.5-7.6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
<svg class="trn-13__ico trn-13__ico--err" viewBox="0 0 20 20" width="18" height="18"><path d="M5.4 5.4l9.2 9.2M14.6 5.4l-9.2 9.2" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
</span>
<span class="trn-13__label" id="trn-13-label" role="status">Ready to deploy</span>
<code class="trn-13__value" id="trn-13-value">data-state="idle"</code>
</div>
<div class="trn-13__meter"><span class="trn-13__fill"></span></div>
</div>
<div class="trn-13__controls">
<button class="trn-13__btn" type="button" data-trn-13-set="idle">idle</button>
<button class="trn-13__btn" type="button" data-trn-13-set="loading">loading</button>
<button class="trn-13__btn" type="button" data-trn-13-set="success">success</button>
<button class="trn-13__btn" type="button" data-trn-13-set="error">error</button>
</div>
<pre class="trn-13__snippet">.trn-13__chip { transition: color 260ms ease, border-color 260ms ease, background-color 260ms ease; }
[data-state="loading"] .trn-13__chip { color: var(--amber); border-color: var(--amber); }
[data-state="success"] .trn-13__chip { color: var(--green); border-color: var(--green); }
[data-state="error"] .trn-13__chip { color: var(--red); border-color: var(--red); }</pre>
</div>
</section>
```
## CSS
```css
.trn-13 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--page: #05080a;
--grid: #0d1418;
--ink: #d6f5df;
--ink: oklch(0.93 0.05 155);
--muted: #6d8878;
--muted: oklch(0.58 0.03 158);
--rule: #16211c;
--green: #3ff58a;
--green: oklch(0.87 0.2 150);
--amber: #ffc857;
--amber: oklch(0.85 0.14 82);
--red: #ff6b6b;
--red: oklch(0.71 0.18 22);
--radius: 4px;
--font-display: ui-monospace, "IBM Plex Mono", "JetBrains Mono", Menlo, monospace;
background: linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 100% 22px, linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 22px 100%, var(--page);
color: var(--ink);
font-family: var(--font-display);
}
.trn-13 *,
.trn-13 *::before,
.trn-13 *::after {
box-sizing: border-box;
}
.trn-13__stage {
display: grid;
place-items: start center;
max-inline-size: 100%;
padding: clamp(22px, 5vw, 52px) 14px;
}
.trn-13__head {
inline-size: 100%;
max-inline-size: 50rem;
min-inline-size: 0;
margin: 0 0 20px;
}
.trn-13__kicker {
margin: 0 0 8px;
font: 400 11px/1 var(--font-display);
letter-spacing: 0.14em;
color: var(--green);
}
.trn-13__heading {
margin: 0 0 8px;
font: 400 clamp(20px, 4vw, 29px)/1.2 var(--font-display);
letter-spacing: -0.01em;
text-wrap: balance;
}
.trn-13__sub {
margin: 0;
max-inline-size: 54ch;
font: 400 13px/1.6 system-ui, sans-serif;
color: var(--muted);
text-wrap: pretty;
}
.trn-13__machine {
inline-size: 100%;
max-inline-size: 50rem;
min-inline-size: 0;
}
.trn-13__chip {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
min-inline-size: 0;
padding: 10px 12px;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: rgba(63, 245, 138, 0.04);
color: var(--muted);
transition: color 260ms ease, border-color 260ms ease, background-color 260ms ease;
}
.trn-13__glyph {
flex: none;
display: grid;
place-items: center;
inline-size: 20px;
block-size: 20px;
}
.trn-13__ico {
grid-area: 1 / 1;
opacity: 0;
transition: opacity 200ms ease;
}
.trn-13__label {
font: 400 13px/1 var(--font-display);
color: var(--ink);
}
.trn-13__value {
margin-inline-start: auto;
font: 400 11.5px/1 var(--font-display);
color: var(--muted);
}
.trn-13__meter {
margin-block-start: 10px;
block-size: 4px;
background: var(--rule);
border-radius: 2px;
overflow: hidden;
}
.trn-13__fill {
display: block;
block-size: 100%;
inline-size: 100%;
background: var(--muted);
transform: scaleX(0);
transform-origin: left;
transition: transform 420ms cubic-bezier(.3, .8, .3, 1), background-color 260ms ease;
}
.trn-13__machine[data-state="idle"] .trn-13__ico--idle {
opacity: 1;
}
.trn-13__machine[data-state="loading"] .trn-13__ico--load {
opacity: 1;
animation: trn-13-spin 900ms linear infinite;
}
.trn-13__machine[data-state="success"] .trn-13__ico--ok {
opacity: 1;
}
.trn-13__machine[data-state="error"] .trn-13__ico--err {
opacity: 1;
}
.trn-13__machine[data-state="loading"] .trn-13__chip {
color: var(--amber);
border-color: var(--amber);
background: rgba(255, 200, 87, 0.06);
}
.trn-13__machine[data-state="success"] .trn-13__chip {
color: var(--green);
border-color: var(--green);
background: rgba(63, 245, 138, 0.07);
}
.trn-13__machine[data-state="error"] .trn-13__chip {
color: var(--red);
border-color: var(--red);
background: rgba(255, 107, 107, 0.07);
}
.trn-13__machine[data-state="loading"] .trn-13__fill {
transform: scaleX(0.62);
background: var(--amber);
}
.trn-13__machine[data-state="success"] .trn-13__fill {
transform: scaleX(1);
background: var(--green);
}
.trn-13__machine[data-state="error"] .trn-13__fill {
transform: scaleX(0.34);
background: var(--red);
}
@keyframes trn-13-spin {
to {
transform: rotate(360deg);
}
}
.trn-13__controls {
inline-size: 100%;
max-inline-size: 50rem;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-block-start: 16px;
}
.trn-13__btn {
min-height: 44px;
padding: 0 14px;
background: transparent;
color: var(--ink);
border: 1px solid var(--rule);
border-radius: var(--radius);
cursor: pointer;
font: 400 12.5px/1 var(--font-display);
transition: border-color 180ms ease, color 180ms ease, background-color 180ms ease;
}
.trn-13__btn:hover {
border-color: var(--green);
color: var(--green);
background: rgba(63, 245, 138, 0.06);
}
.trn-13__btn:focus-visible {
outline: 2px solid var(--green);
outline-offset: 2px;
}
.trn-13__btn[aria-pressed="true"] {
border-color: var(--green);
color: var(--page);
background: var(--green);
}
.trn-13__snippet {
inline-size: 100%;
max-inline-size: 50rem;
margin: 18px 0 0;
padding: 12px;
background: rgba(63, 245, 138, 0.04);
border: 1px solid var(--rule);
border-radius: var(--radius);
font: 400 11px/1.65 var(--font-display);
color: var(--muted);
overflow-x: auto;
}
@media (prefers-reduced-motion: reduce) {
.trn-13__chip {
transition-duration: 140ms;
}
.trn-13__fill {
transition: background-color 140ms ease;
transform: scaleX(1);
}
.trn-13__machine[data-state="loading"] .trn-13__fill,
.trn-13__machine[data-state="success"] .trn-13__fill,
.trn-13__machine[data-state="error"] .trn-13__fill {
transform: scaleX(1);
}
.trn-13__machine[data-state="loading"] .trn-13__ico--load {
animation: none;
}
}
@media (prefers-color-scheme: light) {
.trn-13 {
--page: #f4f9f5;
--grid: #e2ece5;
--ink: #0c1a12;
--muted: #4c6355;
--rule: #cddbd2;
--green: #10763f;
--amber: #8a5b06;
--red: #b02a2a;
}
.trn-13__btn[aria-pressed="true"] {
color: #f4f9f5;
}
}
[data-theme="dark"] .trn-13 {
--page: #05080a;
--grid: #0d1418;
--ink: #d6f5df;
--muted: #6d8878;
--rule: #16211c;
--green: #3ff58a;
--amber: #ffc857;
--red: #ff6b6b;
}
[data-theme="dark"] .trn-13__btn[aria-pressed="true"] {
color: #05080a;
}
.trn-13__theme {
position: relative;
inline-size: 100%;
min-inline-size: 0;
display: flex;
justify-content: flex-end;
margin: 0 0 14px;
}
.trn-13__themecb {
position: absolute;
inline-size: 1px;
block-size: 1px;
clip-path: inset(50%);
overflow: hidden;
}
.trn-13__themebtn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 14px;
border: 1px solid currentColor;
border-radius: 999px;
cursor: pointer;
color: var(--muted);
font: 500 11px/1 ui-monospace, Menlo, monospace;
letter-spacing: 0.1em;
text-transform: uppercase;
transition: color 200ms ease, opacity 200ms ease;
}
.trn-13__themebtn:hover {
color: var(--ink);
}
.trn-13__themecb:focus-visible + .trn-13__themebtn {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.trn-13__themeico {
display: grid;
place-items: center;
inline-size: 15px;
block-size: 15px;
}
.trn-13__themeico svg {
grid-area: 1 / 1;
transition: opacity 200ms ease;
}
.trn-13__sun {
opacity: 0.34;
}
.trn-13__moon {
opacity: 1;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__sun {
opacity: 1;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__moon {
opacity: 0.34;
}
@media (prefers-color-scheme: dark) {
.trn-13:has(#trn-13-theme:checked) {
--page: #f4f9f5;
--grid: #e2ece5;
--ink: #0c1a12;
--muted: #4c6355;
--rule: #cddbd2;
--green: #10763f;
--amber: #8a5b06;
--red: #b02a2a;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__btn[aria-pressed="true"] {
color: #f4f9f5;
}
}
@media (prefers-color-scheme: light) {
.trn-13:has(#trn-13-theme:checked) {
--page: #05080a;
--grid: #0d1418;
--ink: #d6f5df;
--ink: oklch(0.93 0.05 155);
--muted: #6d8878;
--muted: oklch(0.58 0.03 158);
--rule: #16211c;
--green: #3ff58a;
--green: oklch(0.87 0.2 150);
--amber: #ffc857;
--amber: oklch(0.85 0.14 82);
--red: #ff6b6b;
--red: oklch(0.71 0.18 22);
--radius: 4px;
--font-display: ui-monospace, "IBM Plex Mono", "JetBrains Mono", Menlo, monospace;
background: linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 100% 22px, linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 22px 100%, var(--page);
}
.trn-13:has(#trn-13-theme:checked) .trn-13__btn[aria-pressed="true"] {
border-color: var(--green);
color: var(--page);
background: var(--green);
}
.trn-13:has(#trn-13-theme:checked) .trn-13__sun {
opacity: 0.34;
}
.trn-13:has(#trn-13-theme:checked) .trn-13__moon {
opacity: 1;
}
}
```
## JavaScript
```js
const trn13 = document.querySelector('.trn-13');
if (trn13) {
const machine = trn13.querySelector('#trn-13-machine');
const label = trn13.querySelector('#trn-13-label');
const value = trn13.querySelector('#trn-13-value');
const copy = { idle: 'Ready to deploy', loading: 'Deploying to production…', success: 'Deployed — build 4192 live', error: 'Failed — migration 0041 rejected' };
const buttons = [...trn13.querySelectorAll('[data-trn-13-set]')];
const apply = (state) => {
machine.setAttribute('data-state', state);
label.textContent = copy[state];
value.textContent = 'data-state="' + state + '"';
for (const b of buttons) b.setAttribute('aria-pressed', String(b.getAttribute('data-trn-13-set') === state));
};
for (const b of buttons) b.addEventListener('click', () => apply(b.getAttribute('data-trn-13-set')));
apply('idle');
}
```How this works
Four booleans give you sixteen states, and twelve of them are bugs. is-loading, is-success and is-error as separate classes can all be true at once, and eventually will be — a fast retry, a race, a missed cleanup. A single data-state attribute can only hold one value, so the impossible combinations stop being representable rather than being defended against.
Attribute selectors transition exactly like class selectors. [data-state='error'] is just a matcher; the transition lives on the element and interpolates whenever the winning declaration changes. Because each state sets the same few custom properties — an accent, a background, a translate — the transitions are declared once and every state change reuses them.
Put the transition on the element, not on the states. Declaring transition inside [data-state='loading'] means the transition exists only while loading, so entering that state animates and leaving it snaps. The base rule owns the transition; the states own only the values. This is the same asymmetry demo 02 exploits deliberately — here it is a bug, there it is choreography.
State must be readable without colour. A red-to-green transition is invisible to a large number of users, so each state changes its icon and its label as well as its accent, and the status text lives in an aria-live region so assistive technology is told about the change rather than left to notice a repaint.
Make it yours
- Add a fifth state by writing one more attribute selector; nothing else in the CSS changes.
- Move the per-state colours into custom properties on the states and transition the properties themselves for a single interpolating declaration.
- Give the error state a longer duration so failure feels heavier than success.
- Swap the spinner for a static icon if you would rather ship zero keyframes.
- Set
--radiusto 999px for a pill-shaped status chip instead of a card. - Reuse the same attribute on a parent to drive sibling elements from one state change.
Gotchas — read before shipping
- Declaring the transition inside a state selector means the return trip has no transition — the element animates in and snaps out.
- Multiple boolean classes can combine into states your CSS never anticipated; a single attribute makes those combinations unrepresentable.
- Attribute selectors are case-sensitive for values in HTML, so
data-state='Loading'silently matches nothing. - A colour-only state change fails SC 1.4.1 — pair every state with an icon or a label, and announce it in a live region.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by :has(), oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.
Attribute selectors and transitions are baseline since 2013; the floor is the oklch() palette (Chrome 111, Safari 15.4, Firefox 113) with sRGB hex declared first. The 14-line script only sets an attribute, so no scripting feature raises the floor. The in-demo theme toggle is pure CSS and relies on :has(), so an engine without it keeps the operating-system theme instead of flipping.