Hazard Tape Marquee
Two crossed lengths of hazard tape, safety orange with chevron rails over black with a reflective stripe, pulled along in opposite directions by the page's scroll.
DOM + CSS0 deps1 file · 10 KB TSXreduced-motion aware
Props
"use client";
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import type { CSSProperties, RefObject } from "react";
/** Safety orange as a field colour. Black on it reads at 6.3:1. */
const HV_ORANGE = "#FF5B14";
/** The hot highlight, for orange marks on black. */
const HV_HOT = "#FF7A1A";
const HV_BLACK = "#0B0A09";
const HV_BONE = "#EFEBE4";
/**
* The display face: Archivo's width axis does the shouting (wdth 62..125).
* Load it as a variable font with the width axis and expose it as --font-hv,
* e.g. in Next: Archivo({ subsets: ["latin"], axes: ["wdth"], variable: "--font-hv" }).
*/
const HV_DISPLAY = 'var(--font-hv, "Archivo"), "Archivo", "Arial Narrow", ui-sans-serif, system-ui, sans-serif';
/** Which ground a section sits on; the nav reads it to pick its ink. */
type HvGround = "black" | "orange" | "bone";
function hvReducedSub(fn: () => void) {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
}
const hvReducedSnap = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/** True when the reader asked for reduced motion. Live: follows the system setting as it changes. */
function useHvReduced(): boolean {
return useSyncExternalStore(hvReducedSub, hvReducedSnap, () => false);
}
/**
* The scrolling box an element lives in: the nearest ancestor that actually
* scrolls, or null for the window. Resolved on every frame, because a box only
* becomes a scroller once its content is taller than it.
*/
function hvScrollParent(el: Element | null): HTMLElement | null {
let n = el?.parentElement ?? null;
while (n && n !== document.body && n !== document.documentElement) {
const oy = getComputedStyle(n).overflowY;
if ((oy === "auto" || oy === "scroll" || oy === "overlay") && n.scrollHeight > n.clientHeight + 1) return n;
n = n.parentElement;
}
return null;
}
interface HvFrame {
/** The element's box, in viewport pixels. */
rect: DOMRect;
/** The visible part of whatever scrolls it: the scroller's box, or the window. */
view: { top: number; height: number; left: number; width: number };
}
/**
* Run `apply` with the element's position whenever anything scrolls or
* resizes, at most once per frame. One capture-phase scroll listener hears
* every scroller, so the same section works on a page and inside a scrolling
* preview. `apply` writes to the DOM; nothing here re-renders React.
*/
function useHvScrollFrame<T extends HTMLElement>(ref: RefObject<T | null>, apply: (f: HvFrame) => void) {
const applyRef = useRef(apply);
useEffect(() => {
applyRef.current = apply;
});
useEffect(() => {
let raf = 0;
const run = () => {
raf = 0;
const el = ref.current;
if (!el) return;
const sp = hvScrollParent(el);
const r = sp ? sp.getBoundingClientRect() : null;
const view = sp && r ? { top: r.top, height: sp.clientHeight, left: r.left, width: sp.clientWidth } : { top: 0, height: window.innerHeight, left: 0, width: window.innerWidth };
applyRef.current({ rect: el.getBoundingClientRect(), view });
};
const kick = () => {
if (!raf) raf = requestAnimationFrame(run);
};
document.addEventListener("scroll", kick, { capture: true, passive: true });
window.addEventListener("resize", kick);
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(kick) : null;
if (ref.current && ro) ro.observe(ref.current);
run();
return () => {
document.removeEventListener("scroll", kick, { capture: true });
window.removeEventListener("resize", kick);
ro?.disconnect();
if (raf) cancelAnimationFrame(raf);
};
}, [ref]);
}
/**
* The kit's scoped CSS: one focus ring (2px, offset 3px, in the ink of the
* ground it sits on), tabular figures, the press, and transitions off for
* anything a frame loop writes.
*/
const HV_CSS =
".hv-scope{--hv-ring:" + HV_HOT + "}" +
".hv-scope[data-hv-ground=orange],.hv-scope [data-hv-ground=orange]{--hv-ring:" + HV_BLACK + "}" +
".hv-scope[data-hv-ground=bone],.hv-scope [data-hv-ground=bone]{--hv-ring:" + HV_BLACK + "}" +
".hv-scope :focus-visible{outline:2px solid var(--hv-ring)!important;outline-offset:3px!important}" +
".hv-scope .hv-num{font-variant-numeric:tabular-nums}" +
".hv-scope [data-hv-live]{transition:none!important}" +
".hv-scope .hv-press{transition:transform 160ms cubic-bezier(.2,.8,.2,1),background-color 160ms,color 160ms}" +
".hv-scope .hv-press:active{transform:scale(.97)}" +
".hv-scope .hv-arr{transition:transform 220ms cubic-bezier(.2,.8,.2,1)}.hv-scope a:hover .hv-arr,.hv-scope button:hover .hv-arr{transform:translateX(3px)}" +
"@media (prefers-reduced-motion:reduce){.hv-scope .hv-press,.hv-scope .hv-arr{transition:none}}";
const HT_CSS =
".hv-ht-rail{height:9px;background:repeating-linear-gradient(-45deg," + HV_BLACK + " 0 9px," + HV_ORANGE + " 9px 18px)}" +
".hv-ht-track{display:flex;width:max-content;will-change:transform}" +
".hv-ht-unit{display:flex;align-items:center;gap:.5em;padding-right:.5em;white-space:nowrap}" +
".hv-ht-sheen{background:linear-gradient(90deg,#8d8a86 0,#cfccc6 18%,#f6f4ef 24%,#cfccc6 30%,#8d8a86 48%,#8d8a86 100%);background-size:200% 100%}";
export interface HazardTapeMarqueeProps {
/** The phrase the orange tape repeats. */
text?: string;
/** The phrase the black tape repeats. */
backText?: string;
/** The section's ground under the tapes. */
ground?: HvGround;
/** Tilt of the front tape in degrees (the back tape leans the other way). */
angle?: number;
/** Pixels of tape per pixel of scroll. */
speed?: number;
className?: string;
style?: CSSProperties;
}
/**
* HazardTapeMarquee — two crossed tapes pulled along by the page's scroll.
*/
export function HazardTapeMarquee({
text = "Hot fire in progress",
backText = "Keep clear of the stand",
ground = "orange",
angle = -4,
speed = 0.7,
className = "",
style,
}: HazardTapeMarqueeProps) {
const ref = useRef<HTMLElement>(null);
const reduced = useHvReduced();
const live = useRef({ scroll: 0, drift: 0, speed });
useEffect(() => {
live.current.speed = speed;
});
const place = useCallback(() => {
const root = ref.current;
if (!root) return;
const L = live.current;
root.querySelectorAll<HTMLElement>("[data-ht-track]").forEach((track, i) => {
const unit = track.firstElementChild as HTMLElement | null;
const w = unit?.offsetWidth ?? 0;
if (!w) return;
const dir = i === 0 ? -1 : 1;
const x = (L.scroll * L.speed + L.drift) * dir;
const m = ((x % w) + w) % w;
track.style.transform = "translate3d(" + (-m).toFixed(2) + "px,0,0)";
});
const sheen = root.querySelector<HTMLElement>("[data-ht-sheen]");
if (sheen) sheen.style.backgroundPosition = (((L.scroll * 0.25) % 200) - 100).toFixed(1) + "% 0";
}, []);
// The tapes move with the scroll (both ways, so scrolling back pulls them back).
useHvScrollFrame(ref, (f) => {
live.current.scroll = f.view.top + f.view.height - f.rect.top;
place();
});
// And creep a little on their own, as tape in a draught does. Off under reduced motion.
useEffect(() => {
if (reduced) return;
const root = ref.current;
if (!root) return;
let raf = 0;
let on = false;
let last = 0;
const tick = (now: number) => {
raf = 0;
if (!on) return;
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
last = now;
live.current.drift += dt * 22;
place();
raf = requestAnimationFrame(tick);
};
const io = new IntersectionObserver((es) => {
on = !!es[es.length - 1]?.isIntersecting;
last = 0;
if (on && !raf) raf = requestAnimationFrame(tick);
});
io.observe(root);
return () => {
on = false;
io.disconnect();
if (raf) cancelAnimationFrame(raf);
};
}, [reduced, place]);
const bg = ground === "orange" ? HV_ORANGE : ground === "bone" ? HV_BONE : HV_BLACK;
const units = (phrase: string, mark: string) =>
Array.from({ length: 8 }, (_, k) => (
<span key={k} className="hv-ht-unit" aria-hidden={k > 0 ? true : undefined}>
<span>{phrase}</span>
<svg width="0.62em" height="0.62em" viewBox="0 0 24 24" aria-hidden style={{ color: mark }}>
<path d="M12 1.5 L19 12 L12 22.5 L5 12 Z" fill="currentColor" />
</svg>
</span>
));
return (
<section ref={ref} data-hv-ground={ground} aria-label={text} className={"hv-scope @container relative w-full overflow-hidden " + className} style={{ background: bg, fontFamily: HV_DISPLAY, height: "clamp(200px, 26cqw, 320px)", containerType: "inline-size", ...style }}>
<style>{HV_CSS + HT_CSS}</style>
{/* The back tape: black, a silver reflective stripe whose sheen moves with the scroll. */}
<div aria-hidden className="absolute left-[-10%] top-1/2 w-[120%]" style={{ transform: "translateY(-50%) rotate(" + -angle * 1.15 + "deg)", background: HV_BLACK, color: HV_HOT, boxShadow: "0 18px 40px -18px rgba(0,0,0,.6)" }}>
<div data-ht-sheen="" data-hv-live="" className="hv-ht-sheen h-[5px]" />
<div className="overflow-hidden py-2.5">
<div data-ht-track="" data-hv-live="" className="hv-ht-track text-[clamp(18px,2.6cqw,30px)] uppercase leading-none tracking-[0.02em]" style={{ fontWeight: 850, fontVariationSettings: '"wdth" 112' }}>
{units(backText, HV_ORANGE)}
</div>
</div>
<div className="hv-ht-sheen h-[5px]" style={{ backgroundPosition: "60% 0" }} />
</div>
{/* The front tape: safety orange, chevron rails, black stencil-weight type. */}
<div className="absolute left-[-10%] top-1/2 w-[120%]" style={{ transform: "translateY(-50%) rotate(" + angle + "deg)", background: HV_ORANGE, color: HV_BLACK, boxShadow: "0 0 0 2px " + HV_BLACK + ", 0 24px 50px -20px rgba(0,0,0,.55)" }}>
<div className="hv-ht-rail" />
<div className="overflow-hidden py-3">
<div data-ht-track="" data-hv-live="" className="hv-ht-track text-[clamp(30px,5.2cqw,64px)] uppercase leading-none tracking-[-0.01em]" style={{ fontWeight: 900, fontVariationSettings: '"wdth" 68' }}>
{units(text, HV_BLACK)}
</div>
</div>
<div className="hv-ht-rail" style={{ backgroundPosition: "9px 0" }} />
</div>
</section>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this animation
Hi-vis is not only a colour: it is chevrons, tape and reflective stripes. This divider crosses two lengths of hazard tape over a section break. The front one is safety orange edged with black chevron rails, its heavy condensed type reading HOT FIRE IN PROGRESS; behind it, tilted the other way, a black tape in orange type with a silver reflective stripe along each edge. The page's scroll pulls them along in opposite directions, so scrolling back pulls them back, and the reflective stripe catches the light as it moves. Set the phrases, the ground under it and the tilt.
Curator’s note
Free. Both tapes are tracks of eight repeated units moved by translate3d, wrapped by one unit's measured width, so they never run out; their position is the section's distance into the view times speed (scroll back and they pull back) plus a 22px/s creep that stops under reduced motion and off screen. The back tape's retroreflective stripe slides its sheen with the scroll. Every Hi-Vis entry is cut byte for byte from one site (Ardent, a made-up engine maker) and shares its fixed palette: safety orange #FF5B14 as a field, warm black, bone, Archivo's width axis for the shouting.
