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 {
--ivory: #faf9f5;
--slate: #141413;
--clay: #d97757;
--oat: #e3dacc;
--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 4.2s 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 4.2s 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 4.2s 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 4.2s 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 4.2s ease infinite;
}
.row.on {
opacity: 0;
animation: label-fade 4.2s 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.