Flicker headline
A headline switches on like a neon sign: letters catch in random order, then two stutter once more.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const LINES = ["Deploys that", "never sleep"];
const TEXT = LINES.join(" ");
const COUNT = TEXT.replace(/ /g, "").length;
const RUN = 2600; // ms until the last stutter is over
type Plan = { delay: number[]; variant: number[]; stutter: number[] };
// A fresh random order every run: each letter gets its own start time and one of three flicker rhythms.
const makePlan = (): Plan => {
const order = [...Array(COUNT).keys()].sort(() => Math.random() - 0.5);
const delay: number[] = [];
order.forEach((letter, rank) => (delay[letter] = rank * 42 + Math.random() * 90));
const picks = [...Array(COUNT).keys()].sort(() => Math.random() - 0.5);
return { delay, variant: delay.map(() => Math.floor(Math.random() * 3)), stutter: picks.slice(0, 2) };
};
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [plan, setPlan] = useState<Plan | null>(null);
const [run, setRun] = useState(0);
const lastRun = useRef(-RUN);
const flicker = () => {
if (performance.now() - lastRun.current < RUN) return;
lastRun.current = performance.now();
setPlan(makePlan());
setRun((r) => r + 1);
};
useEffect(() => {
if (inView) flicker();
}, [inView]);
const onEnter = (e: PointerEvent) => {
if (e.pointerType !== "touch") flicker();
};
let n = -1;
return (
<div ref={root} className={styles.root} data-on={plan && !reduced ? "" : undefined} onPointerEnter={onEnter} onClick={flicker}>
<p className={styles.kicker}><span className={styles.dot} aria-hidden="true" />Live in 18 regions</p>
<h2 className={styles.title} aria-label={TEXT}>
{LINES.map((line) => (
<span key={`${line}${run}`} className={styles.line} aria-hidden="true">
{[...line].map((c, i) => {
if (c === " ") return <span key={i}> </span>;
n++;
const style = plan && { "--d": `${Math.round(plan.delay[n])}ms` } as CSSProperties;
return (
<span key={i} className={styles.ch} style={style ?? undefined} data-v={plan?.variant[n]} data-stutter={plan?.stutter.includes(n) ? "" : undefined}>
{c}
</span>
);
})}
</span>
))}
</h2>
<button type="button" className={styles.again} onClick={(e) => { e.stopPropagation(); lastRun.current = -RUN; flicker(); }}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5v5M4.2 4a5.5 5.5 0 1 0 7.6 0" /></svg>
Switch on again
</button>
</div>
);
}
demo.module.css
.root {
container-type: size; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
width: 100%; height: 100%; padding: 20px; box-sizing: border-box; background: var(--surface); text-align: center;
}
.kicker {
display: inline-flex; align-items: center; gap: 8px; margin: 0; padding: 5px 12px; border-radius: 999px;
border: 1px solid var(--line); background: var(--white); font-size: 12px; font-weight: 650; color: var(--muted);
}
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px var(--accent-pale); }
.title {
margin: 0; display: grid; font-size: clamp(30px, 11cqi, 78px); font-weight: 750; line-height: 1.02; letter-spacing: -0.035em;
color: var(--accent-text);
}
.line { white-space: nowrap; }
/* Lit glass: accent text with a soft halo. Unlit tubes stay faintly visible, like a sign before it is switched on. */
.ch { display: inline-block; text-shadow: 0 0 .2em color-mix(in srgb, var(--accent) 55%, transparent), 0 0 .6em color-mix(in srgb, var(--accent) 25%, transparent); }
.root[data-on] .ch { animation: f0 .9s step-end var(--d, 0ms) both; }
.root[data-on] .ch[data-v="1"] { animation-name: f1; }
.root[data-on] .ch[data-v="2"] { animation-name: f2; }
/* Two letters catch once more after the sign is on. */
.root[data-on] .ch[data-stutter] { animation: f0 .9s step-end var(--d, 0ms) both, stutter .5s step-end 2.05s; }
/* step-end holds each value until the next stop, so the opacity jumps like a tube catching instead of fading. */
@keyframes f0 { 0% { opacity: .1; } 12% { opacity: .9; } 18% { opacity: .15; } 34% { opacity: .6; } 40% { opacity: .1; } 62%, 100% { opacity: 1; } }
@keyframes f1 { 0% { opacity: .1; } 20% { opacity: .45; } 26% { opacity: .1; } 44% { opacity: 1; } 50% { opacity: .3; } 70%, 100% { opacity: 1; } }
@keyframes f2 { 0% { opacity: .1; } 30% { opacity: 1; } 36% { opacity: .2; } 42% { opacity: .8; } 46% { opacity: .1; } 56%, 100% { opacity: 1; } }
@keyframes stutter { 0% { opacity: 1; } 15% { opacity: .2; } 30% { opacity: .9; } 42% { opacity: .15; } 60%, 100% { opacity: 1; } }
.again {
display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px 0 12px; border-radius: 999px;
border: 1px solid var(--line); background: var(--white); color: var(--ink); font: inherit; font-size: 13px; font-weight: 650;
cursor: pointer; transition: border-color .2s, transform .18s var(--out);
}
.again svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
.again:hover { border-color: var(--accent-soft); }
.again:active { transform: scale(.96); }
.again:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
@container (max-height: 270px) {
.root { gap: 10px; }
.title { font-size: clamp(28px, 9cqi, 46px); }
}
@media (prefers-reduced-motion: reduce) {
.root[data-on] .ch, .root[data-on] .ch[data-stutter] { animation: none; }
}
Use it for
- A short hero headline on a launch or developer tool page
- Moments that announce something going live
- Dark or quiet sections that can carry a glow
Avoid
- Long headlines, the random order makes them hard to read
- Anything shown repeatedly, flicker tires quickly
How it works
Every letter is a span sharing a glow text-shadow and one of three opacity keyframes run with step-end timing, so values jump like a tube catching rather than fading. On each run the code shuffles a start delay per letter into a CSS variable, picks a rhythm per letter and marks two letters for an extra stutter keyframe later on. Changing the key remounts the letters to replay on scroll into view, mouse hover and tap.
Accessibility
The heading carries the full text as its label and the letter spans are aria-hidden. Switch on again is a real button. Reduced motion shows the lit headline with no flicker, which also avoids flashing for people sensitive to it.