Voice — mute is a cut
Variant: Mute cut
The action costs 30ms and the state gets 2.5s, which is the right budget for a control whose job is to be believed. Three sound arcs flicker at different rates while live — three thresholds, so the fan is a crude level meter rather than decoration — and all three die on the same frame the slash starts drawing, because a mute is a switch and a level that fades out claims the voice trailed off on its own. The slash takes 150ms with a paper casing under it, so it reads as cutting through the mic rather than lying on top. Use it wherever muting has social consequences — calls, recording booths, live rooms — and the user needs the state to be unmistakable at a glance.
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>Voice — mute is a cut</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)
);
--_muted: color-mix(in srgb, var(--slate) 55%, transparent);
/* Clock — 6s: live until 44%, muted until 86%, then unmuted for the
tail so the loop closes on sound. The mute itself spends 30ms; the
muted state gets 2.5s, because the state is what the animation is
about and the action is only its edge. */
--cycle: 6s;
--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);
}
.rig {
display: grid;
justify-items: center;
gap: 12px;
}
.stage {
display: flex;
align-items: center;
gap: 6px;
}
.btn {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--paper);
border: 1px solid var(--oat);
animation: press var(--t) ease infinite;
}
/* Two 120ms dips, one per press. The button is the only thing that
moves on the action frames — everything else changes state, which is
the division of labour a real control has: the finger gets motion,
the system gets a cut. */
@keyframes press {
0%,
43.7% {
transform: scale(1);
}
44.7% {
transform: scale(0.96);
}
45.7%,
85.7% {
transform: scale(1);
}
86.7% {
transform: scale(0.96);
}
87.7%,
100% {
transform: scale(1);
}
}
.glyph {
width: 24px;
height: 24px;
overflow: visible;
}
.mic {
stroke: var(--slate);
stroke-width: 1.7;
stroke-linecap: round;
fill: none;
animation: mic-dim var(--t) ease infinite;
}
@keyframes mic-dim {
0%,
45% {
opacity: 1;
}
45.5%,
86% {
opacity: 0.42;
}
88.5%,
100% {
opacity: 1;
}
}
/* The slash draws in 150ms — deliberate, visible, the one flourish. The
paper casing under it is what makes it read as cutting through the
mic rather than lying on top of it. */
.slash,
.slash-under {
fill: none;
stroke-linecap: round;
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: slash var(--t) linear infinite;
}
.slash-under {
stroke: var(--paper);
stroke-width: 5;
}
.slash {
stroke: var(--rust);
stroke-width: 2;
}
@keyframes slash {
0%,
45% {
stroke-dashoffset: 24;
animation-timing-function: var(--ease-out);
}
47.5%,
86% {
stroke-dashoffset: 0;
animation-timing-function: ease;
}
88.5%,
100% {
stroke-dashoffset: 24;
}
}
.arcs {
width: 16px;
height: 26px;
overflow: visible;
}
.arcs path {
fill: none;
stroke: var(--olive);
stroke-width: 2;
stroke-linecap: round;
}
/* Three arcs, three thresholds: the inner one lights for any sound, the
outer only on peaks — so the fan flickering at different rates is a
crude level meter, not decoration. All three die on the same 30ms
frame at 45%: a mute is a switch, and a level that fades out claims
the voice trailed off on its own. */
.a1 {
animation: a1 var(--t) ease infinite;
}
.a2 {
animation: a2 var(--t) ease infinite;
}
.a3 {
animation: a3 var(--t) ease infinite;
}
@keyframes a1 {
0% {
opacity: 0.85;
}
7% {
opacity: 0.3;
}
13% {
opacity: 0.9;
}
22% {
opacity: 0.4;
}
30% {
opacity: 0.85;
}
38%,
44.5% {
opacity: 0.6;
}
45%,
88% {
opacity: 0.12;
}
91% {
opacity: 0.8;
}
96% {
opacity: 0.4;
}
100% {
opacity: 0.85;
}
}
@keyframes a2 {
0% {
opacity: 0.5;
}
8% {
opacity: 0.1;
}
14% {
opacity: 0.7;
}
24% {
opacity: 0.15;
}
31% {
opacity: 0.6;
}
39%,
44.5% {
opacity: 0.25;
}
45%,
88% {
opacity: 0.08;
}
92% {
opacity: 0.55;
}
100% {
opacity: 0.5;
}
}
@keyframes a3 {
0%,
12% {
opacity: 0.08;
}
15% {
opacity: 0.5;
}
19% {
opacity: 0.08;
}
30% {
opacity: 0.08;
}
32.5% {
opacity: 0.45;
}
36%,
44.5% {
opacity: 0.08;
}
45%,
88% {
opacity: 0.05;
}
93% {
opacity: 0.35;
}
100% {
opacity: 0.08;
}
}
.lbl {
display: grid;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
justify-items: center;
}
.lbl span {
grid-area: 1 / 1;
}
.on {
color: var(--_muted);
animation: lbl-on var(--t) ease infinite;
}
.off {
color: var(--rust);
animation: lbl-off var(--t) ease infinite;
}
/* Sequenced, not symmetric: the outgoing word is gone before the
incoming one arrives, so the two never sit at half weight on the
same frame. */
@keyframes lbl-on {
0%,
45% {
opacity: 1;
}
46%,
87.8% {
opacity: 0;
}
89%,
100% {
opacity: 1;
}
}
@keyframes lbl-off {
0%,
45.8% {
opacity: 0;
}
47.2%,
86.4% {
opacity: 1;
}
87.4%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The press dip goes; the cut, the slash, and the label swap stay —
they are colour and opacity, and they are the entire meaning. One
shared 1.5x clock keeps the cut landing on the same frame
everywhere. */
.btn {
animation: none;
}
.mic,
.slash,
.slash-under,
.a1,
.a2,
.a3,
.on,
.off {
animation-duration: calc(var(--t) * 1.5);
}
}
</style>
</head>
<body>
<div
class="rig"
role="img"
aria-label="A microphone button: sound arcs flicker while live, then a press cuts them dead on one frame as a slash draws through the mic"
>
<span class="stage">
<span class="btn">
<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true">
<g class="mic">
<rect x="9.2" y="3" width="5.6" height="10" rx="2.8" />
<path d="M5.5 10.5a6.5 6.5 0 0 0 13 0" />
<path d="M12 17v3" />
</g>
<path class="slash-under" d="M5 5 L19 19" />
<path class="slash" d="M5 5 L19 19" />
</svg>
</span>
<svg class="arcs" viewBox="0 0 16 26" aria-hidden="true">
<path class="a1" d="M3 10 A5 5 0 0 1 3 16" />
<path class="a2" d="M6 7 A8.5 8.5 0 0 1 6 19" />
<path class="a3" d="M9 4 A12 12 0 0 1 9 22" />
</svg>
</span>
<span class="lbl">
<span class="on">Mic on</span>
<span class="off">Muted</span>
</span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.