ns-ui
Lenticule Swing
A hero H1 lockup printed twice behind vertical lenticular slats (the problem statement and the product promise coexisting at once) that shimmers between both at rest and snaps cleanly to the promise as it scrolls out of view.
Use when an above-the-fold H1 that needs to hold two complete messages (a problem statement and a product promise) at once, with the ambiguous overlap itself being the interesting resting frame and a scroll-driven resolve to the second message; pick text-slot-rotate instead for a single message that mechanically cycles through several words, or text-prism-split when the effect should read as one text refracting rather than two messages coexisting.
Install
npx shadcn add https://design.helpmarq.com /r/lenticule-swing.jsonSource
registry/core/lenticule-swing/component.tsx"use client";
import { useEffect, useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// LenticuleSwing — a hero H1 lockup that holds two complete messages behind
// one printed surface, the way a lenticular card holds two images behind a
// ridged plastic sheet. The headline is laid down twice (message A, the
// problem statement; message B, the product promise) into a shared stage cut
// into N thin vertical strips (~pitch px each, measured live off the stage's
// own width via ResizeObserver). Each strip is its own overflow:hidden
// "peephole" div containing two oversized copies of the full headline
// (window-B underneath, window-A on top), offset left by exactly that
// strip's index so the sliver visible through the peephole lines up with
// every other strip's sliver to read as one continuous line. A single custom
// property, --lens-angle (a signed degree value, no CSS unit — used only in
// calc()), drives two things per strip at once: window-A's opacity swings
// from 0 (message B fully wins that strip) to 1 (message A fully wins it)
// around a per-strip jitter offset baked in at mount from a deterministic
// hash of the strip's index — so as the whole headline crosses from "mostly
// A" to "mostly B" a small handful of strips (the ones whose jitter sits
// furthest from the pack) lag behind and keep interlacing after the rest
// have committed, the residual seam a real lenticular print never fully
// resolves. Second, both windows translateX by angle * that strip's centered
// index * a tiny coefficient — a few tenths of a pixel of parallax shimmer
// per strip, the same "look at it from a different angle" cue a ridged sheet
// gives for free.
//
// --lens-angle has exactly one writer: a single rAF loop that measures the
// stage's own getBoundingClientRect() every frame (viewport-relative, so it
// tracks correctly whether the page itself scrolls or the stage sits inside
// a nested overflow-y:auto ancestor — no scroll-event listener, no coupling
// to a particular scroller). At rest (progress 0) a damped spring bounces
// the angle between +-3deg on a 4s half-period (an 8s full swing), so the
// two messages shimmer against each other and neither ever fully resolves —
// the ambiguous in-between IS the resting frame. Once the stage starts
// scrolling out of view, an eased scroll term (easeOutCubic) ramps the angle
// toward a full +9deg commit to message B by `snapAt` (default 40%) of the
// stage's own height, fading the idle spring's weight to 0 over the same
// span so the two drivers never fight — past snapAt the angle holds at its
// resolved value, "snapping cleanly" rather than continuing to creep.
// prefers-reduced-motion skips the spring and the easing entirely: the angle
// is pinned to full message A until progress crosses snapAt, then flips once
// to full message B with no interpolation, no oscillation.
//
// The real <h1> is a visually-hidden element containing message A followed
// by message B as plain text, read once, unconditionally — the strip
// apparatus is aria-hidden and purely decorative. Zero interactive controls;
// this replaces a headline+subhead, not a widget. DOM + CSS only.
// ---------------------------------------------------------------------------
export interface LenticuleSwingProps {
/** the problem statement — dominates the strip apparatus at rest / low angle */
messageA: string;
/** the product promise — the scroll target; strips resolve to this by snapAt */
messageB: string;
/** fraction (0-1) of the stage's own height scrolled before the angle snaps fully to messageB */
snapAt?: number;
/** target strip width in px; actual strip count is derived from live stage width */
pitch?: number;
className?: string;
}
// deg, idle spring bounce amplitude. Deliberately small: the resting frame is
// the one the catalog screenshots and the owner judges, and message A has to
// stay legible there. At +/-0.6deg the A-window's opacity only modulates
// between ~0.90 and 1.0, which reads as a shimmer over ONE readable headline.
// At the original +/-3deg it swung down to ~0.63, so message B showed through
// at ~37% and the two copies composited into unreadable garble.
const ANGLE_IDLE = 0.6;
const ANGLE_RESOLVE = 9; // deg magnitude that fully commits a strip to one message
const HALF_PERIOD = 4000; // ms between idle spring target flips (8s full cycle)
const SPRING_K = 34; // s^-2
const SPRING_ZETA = 0.55; // <1 = visible settle wobble, the "spring-flavored" part
const MIN_STRIPS = 22;
const MAX_STRIPS = 64;
const PARALLAX_COEFF = 0.05; // px of shimmer per deg per centered-strip-index
// Per-strip crossover angle (deg) at which THAT strip's window-A opacity
// passes 0.5, spread across [CROSSOVER_LO, CROSSOVER_HI] by the strip's own
// jitter. CROSSOVER_BAND is how many degrees wide each strip's own fade is.
// Kept narrow on purpose: a strip should snap from ~1 to ~0 quickly, at ITS
// OWN angle, not crossfade in lockstep with every other strip. Without a
// spread wide enough (an earlier version leaned on --j for only a +/-0.06
// opacity nudge, ~6% of the range) almost every strip crossed 50% within the
// same handful of degrees, so mid-scroll the entire headline sat at ~50%
// opacity for both messages at once — two bold headlines double-exposed and
// neither legible, the actual bug a scrolling visitor hits. With the spread
// wide relative to the band, at any given angle during the scroll ramp most
// strips have already fully resolved one way or the other and only a
// narrow subset are actively blending — the "wipe" a real lenticular sheet
// gives, not a uniform dissolve. CROSSOVER_LO sits far enough above the idle
// spring's max excursion that resting message A stays fully opaque; the gap
// between CROSSOVER_HI and ANGLE_RESOLVE is what leaves the "small handful
// of strips lag behind" residual seam in the resolved frame.
const CROSSOVER_LO = 1.5;
const CROSSOVER_HI = 8.1;
const CROSSOVER_MID = (CROSSOVER_LO + CROSSOVER_HI) / 2;
const CROSSOVER_SPREAD = (CROSSOVER_HI - CROSSOVER_LO) / 2;
const CROSSOVER_BAND = 1.2;
function hash01(i: number) {
const x = Math.sin(i * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
function easeOutCubic(t: number) {
return 1 - Math.pow(1 - t, 3);
}
interface Spring {
x: number;
v: number;
t: number;
}
function stepSpring(s: Spring, k: number, zeta: number, dt: number) {
const c = 2 * zeta * Math.sqrt(k);
s.v += (k * (s.t - s.x) - c * s.v) * dt;
s.x += s.v * dt;
}
export function LenticuleSwing({
messageA,
messageB,
snapAt = 0.4,
pitch = 10,
className = "",
}: LenticuleSwingProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const [stripCount, setStripCount] = useState(MIN_STRIPS);
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const measure = (width: number) => {
if (width <= 0) return;
const n = Math.round(width / pitch);
setStripCount(Math.min(MAX_STRIPS, Math.max(MIN_STRIPS, n)));
};
measure(stage.getBoundingClientRect().width);
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w) measure(w);
});
ro.observe(stage);
return () => ro.disconnect();
}, [pitch]);
const strips = useMemo(
() =>
Array.from({ length: stripCount }, (_, i) => ({
i,
centered: i - (stripCount - 1) / 2,
jitter: hash01(i) * 2 - 1, // -1..1, deterministic per index
})),
[stripCount]
);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
let reduced = mq.matches;
const onMqChange = () => {
reduced = mq.matches;
};
mq.addEventListener("change", onMqChange);
let raf = 0;
let last = performance.now();
let hidden = false;
const idle: Spring = { x: -ANGLE_IDLE, v: 0, t: -ANGLE_IDLE };
let flipAt = last + HALF_PERIOD;
let lastWritten = Number.NaN;
let lastCommitted: boolean | null = null;
const setAngle = (deg: number) => {
if (deg === lastWritten) return;
lastWritten = deg;
root.style.setProperty("--lens-angle", String(deg));
};
const progress = () => {
const rect = root.getBoundingClientRect();
if (rect.height <= 0) return 0;
return Math.min(1, Math.max(0, -rect.top / rect.height));
};
const onVisibility = () => {
hidden = document.hidden;
if (!hidden) last = performance.now();
};
document.addEventListener("visibilitychange", onVisibility);
const loop = (now: number) => {
raf = requestAnimationFrame(loop);
if (hidden) return;
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const p = progress();
if (reduced) {
const committed = p >= snapAt;
if (committed !== lastCommitted) {
lastCommitted = committed;
setAngle(committed ? ANGLE_RESOLVE : -ANGLE_RESOLVE);
}
return;
}
if (now >= flipAt) {
idle.t = idle.t > 0 ? -ANGLE_IDLE : ANGLE_IDLE;
flipAt = now + HALF_PERIOD;
}
stepSpring(idle, SPRING_K, SPRING_ZETA, dt);
const pn = Math.min(1, p / snapAt);
const scrollAngle = easeOutCubic(pn) * ANGLE_RESOLVE;
const idleWeight = 1 - pn;
setAngle(Number((scrollAngle + idle.x * idleWeight).toFixed(3)));
};
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
mq.removeEventListener("change", onMqChange);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [snapAt]);
return (
<div
ref={rootRef}
className={`ns-lens-root ${className}`}
style={{ ["--lens-angle" as string]: "-9" }}
>
<style>{CSS}</style>
<h1 className="sr-only">
{messageA} {messageB}
</h1>
<div ref={stageRef} className="ns-lens-stage" aria-hidden="true">
{strips.map((s) => {
const stripStyle = {
left: `${(s.i / stripCount) * 100}%`,
width: `${100 / stripCount}%`,
["--i" as string]: String(s.centered),
["--j" as string]: s.jitter.toFixed(3),
};
const windowStyle = {
left: `${-s.i * 100}%`,
width: `${stripCount * 100}%`,
};
return (
<div key={s.i} className="ns-lens-strip" style={stripStyle}>
<div className="ns-lens-window ns-lens-b" style={windowStyle}>
{messageB}
</div>
<div className="ns-lens-window ns-lens-a" style={windowStyle}>
{messageA}
</div>
</div>
);
})}
</div>
</div>
);
}
const CSS = `
.ns-lens-root{position:relative;width:100%;}
.ns-lens-stage{
position:relative;
width:100%;
height:1.15em;
font-family:var(--font-sans,inherit);
font-size:clamp(28px,6vw,64px);
font-weight:700;
line-height:1.15;
letter-spacing:-0.02em;
color:var(--foreground);
overflow:hidden;
user-select:none;
}
.ns-lens-strip{
position:absolute;
top:0;
height:100%;
overflow:hidden;
}
.ns-lens-window{
position:absolute;
top:0;
height:100%;
display:flex;
align-items:center;
white-space:nowrap;
will-change:transform,opacity;
transform:translateX(calc(var(--lens-angle) * var(--i) * ${PARALLAX_COEFF}px));
}
/*
Both windows paint an opaque --background. Without it the crossfade cannot
work at all: text has a transparent background, so message B stayed fully
visible through the gaps in and between message A's glyphs no matter what
opacity A carried, and the two copies composited into permanently unreadable
garble. Measured A at opacity 1 and B still showing through. With an opaque
fill, A genuinely occludes B, and fading A is what reveals B — which is also
what makes the per-strip flip read as a lenticular card instead of a blend.
*/
.ns-lens-window{
background:var(--background);
}
.ns-lens-b{
opacity:1;
z-index:1;
}
.ns-lens-a{
z-index:2;
/*
Each strip fades from fully opaque to fully transparent over a narrow
CROSSOVER_BAND-wide window centered on ITS OWN crossover angle (spread
across [CROSSOVER_LO, CROSSOVER_HI] by --j). That keeps most strips
fully committed to one message at any given angle, so the mid-scroll
frame reads as a wipe between two legible headlines instead of both
superimposed at ~50%.
*/
opacity:clamp(0, calc(0.5 - (var(--lens-angle) - (${CROSSOVER_MID} + var(--j) * ${CROSSOVER_SPREAD})) / ${CROSSOVER_BAND}), 1);
}
@media (prefers-reduced-motion: reduce){
.ns-lens-window{transition:none;}
}
`;
Build spec
A hero headline rendered twice — messageA (the problem) and messageB (the promise) — into a shared stage cut into N thin vertical strips (~pitch px each, default 10, actual count derived live from the stage's own width via ResizeObserver, clamped 22-64). Each strip is its own overflow:hidden peephole div holding two oversized copies of the full headline text (window for B underneath, window for A on top), each shifted left by exactly that strip's index times the stage width so the visible sliver in every strip lines up with its neighbors into one continuous line — the classic 'replicate the whole image, offset each copy, clip with a small window' lenticular-strip trick, no canvas. One custom property, --lens-angle (a signed degree number, used only inside calc(), no CSS unit), drives every strip: window-A's opacity is clamp(0, 0.5 - (angle/9)*0.5 + strip-jitter*0.12, 1) where strip-jitter is a deterministic hash of the strip's index baked in at mount (-1..1) — so as the whole headline sweeps from angle -9 (message A fully wins every strip) to +9 (message B fully wins every strip) the strips whose jitter sits furthest from the pack lag behind and keep interlacing a beat after the rest have committed, a residual seam a real lenticular print never fully resolves. Both windows in a strip also translateX by angle * that strip's centered index (i - (count-1)/2) * 0.05px, a few tenths of a pixel of per-strip parallax shimmer, the 'tilt the plastic sheet' cue. --lens-angle has exactly one writer: a single rAF loop that reads the component root's own getBoundingClientRect() every frame (viewport-relative, so it is correct whether the page itself scrolls or the root sits inside a nested overflow-y:auto ancestor — no scroll-event listener, no scroller coupling). At rest (scroll progress 0) a damped spring (k=34 s^-2, zeta=0.55, visible settle wobble) bounces the angle between -3 and +3 deg on a 4s half-period — an 8s full swing — so the two messages shimmer against each other and the resting frame is deliberately the ambiguous in-between, never a clean read of either. Once the root starts scrolling out of the viewport, progress = clamp(-rect.top/rect.height, 0, 1); an eased scroll term (easeOutCubic(min(progress/snapAt,1)) * 9deg) ramps the angle toward a full commit to messageB by snapAt (default 0.4) of the root's own height, while the idle spring's contribution fades to 0 weight over that same span so the two drivers hand off rather than fight — past snapAt the angle holds at its resolved value, a clean snap rather than a continuing creep. Consumers size 'the hero' by giving the root a min-height via className (the demo uses flex + min-h-[380px]); snapAt is relative to the root's rendered height, not just the single text line. The real <h1> is a visually-hidden (sr-only) element containing messageA followed by messageB as plain text, read exactly once, unconditionally, with no interaction required; the entire strip apparatus is aria-hidden. Zero interactive controls — this replaces a headline plus subhead, not a widget, so the a11y control audit's Tab-reachability rule does not apply. prefers-reduced-motion: the spring and the easing are both skipped; the angle is pinned to -9 (full messageA) at progress 0 and flips once, with no interpolation, to +9 (full messageB) the instant progress crosses snapAt — no oscillation, no creep, still legible and still resolves. All ink is var(--foreground); no color other than the token is used anywhere in the strip apparatus. Zero dependencies.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| messageA | string | — | the problem statement — dominates the strip apparatus at rest / low angle |
| messageB | string | — | the product promise — the scroll target; strips resolve to this by snapAt |
| snapAt? | number | 0.4 | fraction (0-1) of the stage's own height scrolled before the angle snaps fully to messageB |
| pitch? | number | 10 | target strip width in px; actual strip count is derived from live stage width |
| className? | string | — | — |