Back to the library
Toggle — commit wipe
Variant: Wipe
The thumb is gone; the commit is a clip-path edge crossing the pill in 180ms, with the label rolling from OFF to ON a hundred milliseconds behind it. Anything slower and the wipe stops reading as a decision and starts reading as a progress bar. The sharpest of the three — reach for it on a labelled toggle where the word, not a moving part, is what the user is reading.
controlclip-path
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Toggle — commit wipe</title>
<style>
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
/* Clock — every duration in the document runs off --t. */
--cycle: 4.2s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.pill {
position: relative;
display: grid;
place-items: center;
width: 132px;
height: 48px;
border-radius: 24px;
background: var(--oat);
overflow: hidden;
animation: pill-press var(--t) infinite;
}
/* No thumb to follow — the commit is the colour crossing the control.
clip-path keeps that on the compositor; an animated width would not,
and would drag the label along with it. 180ms edge to edge: slower and
the wipe stops reading as a decision and starts reading as progress. */
.on-layer {
position: absolute;
inset: 0;
background: var(--clay);
clip-path: inset(0 100% 0 0);
animation: wipe var(--t) infinite;
}
.window {
position: relative;
height: 18px;
overflow: hidden;
}
/* A grid rather than two stacked blocks: it lays out identically here, and
it is what lets the reduced-motion branch put both labels in one cell
without re-plumbing the layout underneath them. */
.stack {
display: grid;
animation: roll var(--t) infinite;
}
.row {
height: 18px;
line-height: 18px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.16em;
/* Letter-spacing adds a trailing gap after the last glyph, which pushes
centred caps left by half of it. Indent by one step to put it back. */
text-indent: 0.16em;
text-align: center;
}
.row.on {
color: var(--ivory);
}
@keyframes wipe {
0%,
12.4% {
clip-path: inset(0 100% 0 0);
animation-timing-function: var(--ease-out);
}
16.7%,
56.2% {
clip-path: inset(0 0 0 0);
animation-timing-function: var(--ease-out);
}
60.5%,
100% {
clip-path: inset(0 100% 0 0);
}
}
/* The label rolls 100ms behind the leading edge on the way in and 60ms
ahead of it on the way out, so the word is never stranded on the wrong
background for longer than a frame or two. */
@keyframes roll {
0%,
14.8% {
transform: translateY(0);
animation-timing-function: var(--ease-out);
}
18.6%,
54.8% {
transform: translateY(-18px);
animation-timing-function: var(--ease-out);
}
58.6%,
100% {
transform: translateY(0);
}
}
@keyframes pill-press {
0%,
9.5% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
12.4% {
transform: scale(0.97);
animation-timing-function: var(--ease-out);
}
16.7%,
52.4% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
55.2% {
transform: scale(0.97);
animation-timing-function: var(--ease-out);
}
59.5%,
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.pill {
animation: none;
}
.on-layer {
clip-path: none;
opacity: 0;
animation: fill-fade var(--t) ease infinite;
}
.stack {
animation: none;
transform: translateY(0);
}
/* The travel is removed by collapsing the two rows into the one grid
cell they already share a track with — no repositioning, no change of
containing block, nothing for the surrounding layout to notice. */
.row {
grid-area: 1 / 1;
}
/* Both halves of the crossfade, not just the arriving one: stacked in a
single cell, an ON that only fades in leaves OFF legible underneath it
and the label reads as both states at once. */
.row.off {
animation: label-fade-out var(--t) ease infinite;
}
.row.on {
opacity: 0;
animation: label-fade var(--t) ease infinite;
}
@keyframes fill-fade {
0%,
12.4% {
opacity: 0;
}
16.7%,
56.2% {
opacity: 1;
}
60.5%,
100% {
opacity: 0;
}
}
@keyframes label-fade {
0%,
13.5% {
opacity: 0;
}
17.5%,
55.5% {
opacity: 1;
}
59.5%,
100% {
opacity: 0;
}
}
@keyframes label-fade-out {
0%,
13.5% {
opacity: 1;
}
17.5%,
55.5% {
opacity: 0;
}
59.5%,
100% {
opacity: 1;
}
}
}
</style>
</head>
<body>
<div class="pill" role="img" aria-label="Toggle committing from off to on">
<div class="on-layer"></div>
<div class="window">
<div class="stack">
<div class="row off">OFF</div>
<div class="row on">ON</div>
</div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.