Voice — note playback
Variant: Note playback
A voice message playing back: the playhead crosses the waveform at constant speed because playback is a clock, and an eased sweep would claim the middle of the message played faster than its ends. Played and unplayed are one set of bars in two colours — the lit row is a clipped copy driven by the same linear clock as the head, so the wipe and the position can never drift a frame apart. The bars themselves hold still, since the recording already happened and bars that dance during playback claim a live signal. The rewind is the one eased move in the loop: 210ms, a gesture rather than a datum. Reach for it in chat threads and inboxes, where a voice note needs to show its progress without a timestamp doing all the work.
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 — note playback</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;
--round: 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 — 5.2s: playback sweeps for 70% of it, holds a beat at the
end, rewinds in 210ms, and rests before replaying. The sweep is
linear because playback is a clock; the rewind is the one eased
move in the loop — a thing already on screen travelling home, so
it gets the ease-in-out that on-screen moves get. */
--cycle: 5.2s;
--t: calc(var(--cycle) / var(--speed));
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 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);
}
.player {
display: flex;
align-items: center;
gap: 14px;
padding: 13px 18px;
border-radius: calc(14px * var(--round));
background: var(--paper);
border: 1px solid var(--oat);
}
.transport {
position: relative;
width: 16px;
height: 16px;
flex: none;
}
.transport svg {
position: absolute;
inset: 0;
}
.tri {
fill: var(--clay-d);
animation: tri var(--t) ease infinite;
}
.bars {
fill: var(--clay-d);
animation: bars var(--t) ease infinite;
}
/* Sequenced swap: the outgoing glyph is gone before the incoming one
arrives, so the two never share a frame at half weight. */
@keyframes tri {
0%,
4% {
opacity: 1;
}
5%,
75.4% {
opacity: 0;
}
76.4%,
100% {
opacity: 1;
}
}
@keyframes bars {
0%,
5.4% {
opacity: 0;
}
6.4%,
74% {
opacity: 1;
}
75%,
100% {
opacity: 0;
}
}
.wave {
position: relative;
width: 118px;
height: 26px;
}
.row {
position: absolute;
inset: 0;
display: flex;
align-items: center;
gap: 2px;
}
.row i {
flex: none;
width: 3px;
border-radius: 1.5px;
background: var(--oat);
}
/* Played and unplayed are one waveform in two colours: the lit row is a
clipped copy of the dim one, driven by the same linear clock as the
playhead — one clock, two renderings, so the wipe and the head can
never drift a frame apart. */
.row.lit i {
background: var(--clay);
}
.row.lit {
clip-path: inset(0 118px 0 0);
animation: wipe var(--t) linear infinite;
}
@keyframes wipe {
0%,
4% {
clip-path: inset(0 118px 0 0);
}
74%,
80% {
clip-path: inset(0 0 0 0);
animation-timing-function: var(--ease-in-out);
}
84%,
100% {
clip-path: inset(0 118px 0 0);
}
}
.head {
position: absolute;
top: -2px;
left: -1px;
width: 2px;
height: 30px;
border-radius: 1px;
background: var(--slate);
animation: head var(--t) linear infinite;
}
@keyframes head {
0%,
4% {
transform: translateX(0);
}
74%,
80% {
transform: translateX(118px);
animation-timing-function: var(--ease-in-out);
}
84%,
100% {
transform: translateX(0);
}
}
/* n heights, one voice: a swell into the middle of the phrase and a
trail at the end. Static on purpose — the recording already happened,
and bars that dance during playback claim a signal that is not
playing. */
.row i:nth-child(1) {
height: 7px;
}
.row i:nth-child(2) {
height: 12px;
}
.row i:nth-child(3) {
height: 18px;
}
.row i:nth-child(4) {
height: 14px;
}
.row i:nth-child(5) {
height: 20px;
}
.row i:nth-child(6) {
height: 24px;
}
.row i:nth-child(7) {
height: 16px;
}
.row i:nth-child(8) {
height: 9px;
}
.row i:nth-child(9) {
height: 13px;
}
.row i:nth-child(10) {
height: 21px;
}
.row i:nth-child(11) {
height: 25px;
}
.row i:nth-child(12) {
height: 18px;
}
.row i:nth-child(13) {
height: 11px;
}
.row i:nth-child(14) {
height: 7px;
}
.row i:nth-child(15) {
height: 10px;
}
.row i:nth-child(16) {
height: 16px;
}
.row i:nth-child(17) {
height: 20px;
}
.row i:nth-child(18) {
height: 14px;
}
.row i:nth-child(19) {
height: 9px;
}
.row i:nth-child(20) {
height: 12px;
}
.row i:nth-child(21) {
height: 17px;
}
.row i:nth-child(22) {
height: 11px;
}
.row i:nth-child(23) {
height: 8px;
}
.row i:nth-child(24) {
height: 6px;
}
.time {
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--_muted);
}
@media (prefers-reduced-motion: reduce) {
/* The wipe is progress and stays, on a 1.6x clock. The playhead is
pure travel and goes — the boundary between clay and oat is already
the position. */
.row.lit,
.tri,
.bars {
animation-duration: calc(var(--t) * 1.6);
}
.head {
animation: none;
opacity: 0;
}
}
</style>
</head>
<body>
<div
class="player"
role="img"
aria-label="A voice note playing: a playhead sweeps the waveform at constant speed, bars turn clay as they are heard, then the head rewinds and it plays again"
>
<span class="transport">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path class="tri" d="M4 2.5 L13.5 8 L4 13.5 Z" />
</svg>
<svg viewBox="0 0 16 16" aria-hidden="true">
<g class="bars">
<rect x="3.5" y="2.5" width="3.4" height="11" rx="1.2" />
<rect x="9.1" y="2.5" width="3.4" height="11" rx="1.2" />
</g>
</svg>
</span>
<span class="wave">
<span class="row">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i
><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
</span>
<span class="row lit" aria-hidden="true">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i
><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
</span>
<span class="head"></span>
</span>
<span class="time">0:07</span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.