Voice — speaking halo
Variant: Speaking halo
A call tile whose halo is a level, not a clock: it swells with each syllable, dies with the voice, and spends half the loop silent. That is the whole difference from a beacon — a ring leaving on a fixed period claims a process, this claims a person. The envelope is asymmetric the way a voice is, ~120ms attacks against releases up to two and a half times longer, and the SPEAKING label underneath is the voice-activity detector’s binary verdict on the same signal: it opens a beat behind the first syllable — detection has latency — and hangs on ~400ms after the level dies, the same hangover a real VAD uses so nobody is dropped at every comma. Reach for it on call rosters and voice rooms, where "who is talking" is the page’s most-read datum.
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 — speaking halo</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 — 4.8s: two phrases of speech and half the cycle in silence.
The silence is load-bearing — a halo that never rests is a beacon
on a timer, and the whole claim here is that it is not one. */
--cycle: 4.8s;
--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);
}
.tile {
display: grid;
justify-items: center;
gap: 10px;
}
.seat {
position: relative;
width: 56px;
height: 56px;
}
/* The halo is a level, not a clock: it swells with each syllable and dies
with the voice. It never detaches from the avatar and travels outward —
a ring that leaves on a fixed period is a beacon, and a beacon claims a
process where this claims a person. */
.halo {
position: absolute;
inset: -7px;
border-radius: 50%;
border: 2px solid var(--olive);
opacity: 0;
transform: scale(0.86);
animation: level var(--t) linear infinite;
}
.face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--olive) 16%, var(--paper));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--olive) 40%, transparent);
font-size: 15px;
font-weight: 600;
color: color-mix(in srgb, var(--olive) 68%, var(--slate));
}
/* The envelope: syllabic swells with a ~120ms attack and releases up to
two and a half times longer, longest at phrase ends — that asymmetry
is what a voice actually does: onset is nearly instant, decay trails.
Symmetric swells read as breathing. Timing function is linear on the
container; each segment carries its own curve. */
@keyframes level {
0%,
3% {
opacity: 0;
transform: scale(0.86);
animation-timing-function: var(--ease-out);
}
5.5% {
opacity: 0.9;
transform: scale(1);
animation-timing-function: ease;
}
9% {
opacity: 0.35;
transform: scale(0.92);
animation-timing-function: var(--ease-out);
}
11.5% {
opacity: 1;
transform: scale(1.04);
animation-timing-function: ease;
}
16% {
opacity: 0.4;
transform: scale(0.93);
animation-timing-function: var(--ease-out);
}
18.5% {
opacity: 0.85;
transform: scale(0.99);
animation-timing-function: ease;
}
24.5% {
opacity: 0;
transform: scale(0.86);
}
/* Breath between phrases — real, but shorter than the tail silence. */
29%,
30% {
opacity: 0;
transform: scale(0.86);
animation-timing-function: var(--ease-out);
}
32.5% {
opacity: 0.95;
transform: scale(1.02);
animation-timing-function: ease;
}
36% {
opacity: 0.4;
transform: scale(0.92);
animation-timing-function: var(--ease-out);
}
38.5% {
opacity: 0.9;
transform: scale(1);
animation-timing-function: ease;
}
42% {
opacity: 0.55;
transform: scale(0.95);
animation-timing-function: var(--ease-out);
}
44.5% {
opacity: 0.8;
transform: scale(0.98);
animation-timing-function: ease;
}
51%,
100% {
opacity: 0;
transform: scale(0.86);
}
}
.name {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
}
/* The word is the voice-activity detector's verdict where the halo is
the raw level: binary, opens a beat behind the first syllable —
detection has latency, not clairvoyance — and hangs on ~400ms after
the level dies, the same hangover a real VAD uses so a speaker is
not dropped at every comma. */
.state {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: color-mix(in srgb, var(--olive) 75%, var(--slate));
animation: verdict var(--t) ease infinite;
}
@keyframes verdict {
0%,
4% {
opacity: 0;
}
6.5%,
53% {
opacity: 1;
}
59.5%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The swell goes, the brightness stays: the halo becomes a pure
opacity envelope on a 1.6x clock, which still reads as a voice
rising and falling — the scale was texture, the light was signal. */
.halo {
animation: level-dim calc(var(--t) * 1.6) linear infinite;
transform: none;
}
.state {
animation-duration: calc(var(--t) * 1.6);
}
@keyframes level-dim {
0%,
3% {
opacity: 0;
}
5.5% {
opacity: 0.8;
}
9% {
opacity: 0.35;
}
11.5% {
opacity: 0.9;
}
16% {
opacity: 0.4;
}
18.5% {
opacity: 0.75;
}
24.5% {
opacity: 0;
}
29%,
30% {
opacity: 0;
}
32.5% {
opacity: 0.85;
}
36% {
opacity: 0.4;
}
38.5% {
opacity: 0.8;
}
42% {
opacity: 0.5;
}
44.5% {
opacity: 0.7;
}
51%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="tile"
role="img"
aria-label="A call tile: a halo around the avatar swells and dies with the rhythm of a voice, and a Speaking label holds through the pauses"
>
<span class="seat">
<span class="halo"></span>
<span class="face">AN</span>
</span>
<span class="name">Ana</span>
<span class="state">Speaking</span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.