Throughline
Integration Logo Paths for React
Glass tiles that ride dotted lines through your headline. Each tile slides out from under a small station, grows as it travels, turns a little on the way, and dives into the far station, and a tile passing over a colored letter picks up its color through the glass. Rest the pointer on one and its whole line comes to a stop, like a cable car line, while the tile turns upright and shows its label. Lines can run behind the content as well as over it.
<Throughline />The demo follows as you drag.
Okto integrations
Okto keeps billing, support and analytics in sync both ways. Edit a record in one, and the rest have it within a second.
Browse 40 integrationsimport * as React from "react";
import { Throughline, type ThroughlineItem, type ThroughlineLine } from "@/components/ui/throughline";
/** A small app icon: a colored square with a white or ink glyph. */
function AppIcon({ bg, children }: { bg: string; children: React.ReactNode }) {
return (
<span className="flex size-full items-center justify-center rounded-[26%] shadow-[inset_0_1px_0_rgb(255_255_255/0.25)]" style={{ background: bg }}>
<svg viewBox="0 0 24 24" className="size-[62%]" fill="none" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
</span>
);
}
const LINES: ThroughlineLine[] = [
{ from: [5, 84], to: [95, 10], bend: -0.12 },
{ from: [8, 8], to: [92, 90], bend: 0.14 },
{ from: [2, 46], to: [98, 38], bend: -0.24, behind: true },
];
const ITEMS: ThroughlineItem[] = [
{
label: "Tally · Billing",
line: 0,
icon: (
<AppIcon bg="#c6ff3d">
<path d="M12 3.5 13.9 10l6.6 2-6.6 2L12 20.5 10.1 14 3.5 12l6.6-2Z" fill="#0b0b0f" />
</AppIcon>
),
},
{
label: "Halfmoon · Support inbox",
line: 0,
icon: (
<AppIcon bg="#0b0b0f">
<path d="M16.5 4.6a8 8 0 1 0 2.9 10.9A6.5 6.5 0 0 1 16.5 4.6Z" fill="#fff" />
</AppIcon>
),
},
{
label: "Wavelet · Product analytics",
line: 0,
icon: (
<AppIcon bg="#2f3cff">
<path d="M3 12c2.2-5 4.3-5 6.5 0s4.3 5 6.5 0 3.3-4 5-2" stroke="#fff" strokeWidth="2.6" />
</AppIcon>
),
},
{
label: "Gridline · Spreadsheets",
line: 1,
icon: (
<AppIcon bg="#ffffff">
<rect x="4" y="4" width="7" height="7" rx="2" fill="#0b0b0f" />
<rect x="13" y="4" width="7" height="7" rx="3.5" fill="#ff3b1f" />
<rect x="4" y="13" width="7" height="7" rx="3.5" fill="#ff3b1f" />
<rect x="13" y="13" width="7" height="7" rx="2" fill="#0b0b0f" />
</AppIcon>
),
},
{
label: "Roundup · CRM",
line: 1,
icon: (
<AppIcon bg="linear-gradient(140deg,#ff5fa2,#ff7a1a)">
<circle cx="12" cy="12" r="6.5" stroke="#fff" strokeWidth="2.8" />
<circle cx="12" cy="12" r="2" fill="#fff" />
</AppIcon>
),
},
{
label: "Relay Mail · Email",
line: 1,
icon: (
<AppIcon bg="#0b0b0f">
<path d="M4 7.5 12 13l8-5.5M5 6h14a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1Z" stroke="#c6ff3d" strokeWidth="2" />
</AppIcon>
),
},
{
label: "Stint · Time tracking",
line: 2,
icon: (
<AppIcon bg="#7b5cff">
<path d="M12 7v5l3 2" stroke="#fff" strokeWidth="2.4" />
<circle cx="12" cy="12" r="8" stroke="#fff" strokeWidth="2.4" />
</AppIcon>
),
},
{
label: "Bolt Forms · Surveys",
line: 2,
icon: (
<AppIcon bg="#ff3b1f">
<path d="M13.5 3 6 13.5h5.5L10.5 21 18 10.5h-5.5Z" fill="#fff" />
</AppIcon>
),
},
];
export function ThroughlineDemo() {
return (
<section className="w-full overflow-hidden rounded-[28px] bg-white py-14 text-center text-zinc-950 sm:py-16 dark:bg-[#08080c] dark:text-white">
<p className="text-[13px] font-medium tracking-[-0.01em] text-zinc-500 dark:text-zinc-400">Okto integrations</p>
<Throughline lines={LINES} items={ITEMS} className="text-[#ff3b1f] dark:text-[#ff5a3a]">
<h2 className="px-4 pb-[max(3.5rem,7.5cqi)] pt-[max(4.5rem,9.5cqi)] text-[clamp(3.25rem,16.5cqi,12.5rem)] font-black leading-[0.8] tracking-[-0.065em] text-[#ff3b1f] dark:text-[#ff5a3a]">
Everything
</h2>
</Throughline>
<p className="mx-auto max-w-[23rem] px-6 text-pretty text-[15px] leading-relaxed text-zinc-600 dark:text-zinc-400">
Okto keeps billing, support and analytics in sync both ways. Edit a record in one, and the
rest have it within a second.
</p>
<a
href="#"
className="mt-7 inline-flex h-11 items-center rounded-full bg-[#ff3b1f] px-6 text-sm font-semibold text-white shadow-[0_8px_20px_-8px_rgb(255_59_31/0.7)] transition-transform duration-150 ease-out active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ff3b1f] dark:bg-[#ff5a3a]"
>
Browse 40 integrations
</a>
</section>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Throughline: Glass tiles that ride dotted lines through your headline. Each tile slides out from under a small station, grows as it travels, turns a little on the way, and dives into the far station, and a tile passing over a colored letter picks up its color through the glass. Rest the pointer on one and its whole line comes to a stop, like a cable car line, while the tile turns upright and shows its label. Lines can run behind the content as well as over it. This demo, Integrations: A sync tool's integrations, riding three lines through one giant word. One line runs behind the letters, and the glass turns red where a tile crosses one. 1. Install it: npx shadcn@latest add @keyframeui/throughline-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/throughline-demo.json instead.) It adds the demo, components/throughline-demo.tsx, and the component, components/ui/throughline.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Throughline> on its own. 3. These props set its feel. Change them before you change the file: - duration (default 26): Seconds a tile takes to ride a line as long as the box is wide. Longer lines take longer, so every tile keeps one pace. - brakeDuration (default 0.3): How long a line takes to slow to a stop when one of its tiles is held, in seconds. Longer and the tile glides on past the pointer before it stops. - releaseDuration (default 0.6): How long a line takes to get back up to speed once it's let go, in seconds. - stations (default true): Draws the small square stations at each end of a line. 4. Don't change, in components/ui/throughline.tsx: - Each ride is a Web Animation on the tile's transform, plus one on its glass's opacity, run linearly through `RIDE.samples` poses so tiles keep one pace off the main thread. - Braking and releasing step a line's animations by hand through `currentTime`, one rAF per line with speed as u²; a line goes back to running on its own only at full speed. - A hold starts from `pointermove` after the hover intent and lets go after the linger, so a sweep across, or a tile drifting under a still pointer, never stops a line. - Off screen every line pauses where it is, and a rebuild (a resize, a new `duration`) puts each tile back at the place saved in `fracs`, so nothing jumps to a start. - Reduced motion builds no rides: tiles sit full size at their `restingPoints`, and hover, focus and tap still show labels. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/throughline.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/throughline.tsx and installs clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/throughlineInstall the dependencies
npm i clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/throughline.tsx"use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /** * Keep: * - Each ride is a Web Animation on the tile's transform, plus one on its glass's opacity, run * linearly through `RIDE.samples` poses so tiles keep one pace off the main thread. * - Braking and releasing step a line's animations by hand through `currentTime`, one rAF per line * with speed as u²; a line goes back to running on its own only at full speed. * - A hold starts from `pointermove` after the hover intent and lets go after the linger, so a sweep * across, or a tile drifting under a still pointer, never stops a line. * - Off screen every line pauses where it is, and a rebuild (a resize, a new `duration`) puts each * tile back at the place saved in `fracs`, so nothing jumps to a start. * - Reduced motion builds no rides: tiles sit full size at their `restingPoints`, and hover, focus * and tap still show labels. */ const TIMING = { /** Seconds to ride a line as long as the box is wide (the `duration` prop). */ duration: 26, /** The ride's easing between its sampled poses, so every tile keeps one pace. */ ride: "linear", /** A held line slowing to a stop, in seconds (the `brakeDuration` prop). */ brakeDuration: 0.3, /** The line getting back up to speed once let go, in seconds (the `releaseDuration` prop). */ releaseDuration: 0.6, /** The pointer resting on a tile before its line stops, in ms. */ intent: 100, /** After the pointer leaves, before the line moves again, in ms. */ linger: 250, /** The first fade-in: its duration and curve as Tailwind classes, and ms between tiles. */ enter: { fade: "duration-500 ease-out", stagger: 60 }, /** A held tile turning upright and lifting, as Tailwind classes. */ face: "duration-[240ms] ease-[cubic-bezier(0.23,1,0.32,1)]", /** The held tile's label rising in and dropping away, as Tailwind classes. */ label: "duration-[180ms] ease-[cubic-bezier(0.23,1,0.32,1)]", } as const; /* ───────────────────────────────────────────────────────── * THROUGHLINE STORYBOARD * * rest dotted lines thread across the box, each running * from one small square station to another, over (or * under) whatever you put inside: a headline, a card. * Glass tiles ride them, spaced evenly. A tile over a * colored letter picks up its color through the glass * ride one steady pace, linear: a line as long as the box * is wide takes `duration` (26s) end to end. Each line * starts at its own point in the loop, so no two lines * move in step * launch a tile slides out from under its station at 16% of * its size and grows to full size over the first 14% * of the line, turning slowly as it goes (7° over the * whole ride). Over the last 14% it dives back into * the far station, fading out in the last 4% * view on first paint the tiles fade in where they already * are, 60ms apart, over 0.5s. Nothing jumps to a start * brake rest the pointer on a tile for 100ms and its whole * line stops, like a cable car line: every tile on it * slows to a stop in 0.3s (speed falls as (1 − t)²). * A sweep across does nothing; a tile drifting under a * still pointer does nothing. Other lines keep going * face the held tile turns upright and lifts to 1.08 in * 0.24s, and its label rises 6px under it in 0.18s * release 0.25s after the pointer leaves, the line picks up * speed again over 0.6s (t²) and the tile tilts back * touch a tap holds a tile like a hover; a second tap, or a * tap anywhere else, lets it go * keys tiles with `href` are links: Tab reaches them, focus * holds the line the same way, Enter follows the link * sleep off screen every line pauses where it is and goes on * from there when it's back. The ride runs as Web * Animations on transform, off the main thread * resize lines live in % of the box; on resize each ride is * rebuilt for the new size and keeps its place * reduced nothing rides: the tiles sit full size, spread along * their lines. Hover, focus and tap still show labels * ───────────────────────────────────────────────────────── */ const RIDE = { samples: 120, // keyframes per ride; linear between them launch: 0.14, // share of a line over which a tile grows out of its station (and dives back in) far: 0.16, // a tile's scale at a station fade: 0.04, // share of the line at each end over which a tile fades tilt: 11, // deg, the largest resting tilt sway: 7, // deg a tile turns over its ride lift: 1.08, // scale of a held tile } as const; export type ThroughlinePoint = [x: number, y: number]; export interface ThroughlineLine { /** Where tiles leave from: `[x, y]` in % of the box. */ from: ThroughlinePoint; /** Where they arrive: `[x, y]` in % of the box. */ to: ThroughlinePoint; /** How far the line bows, as a share of its length. Positive bows it to the right of its direction (down, for a line running left to right). Default `0`. */ bend?: number; /** Run this line and its tiles behind the content instead of over it. */ behind?: boolean; } export interface ThroughlineItem { /** What the tile stands for. Shown under it when held, and read by screen readers. */ label: string; /** What sits in the tile: an svg, a logo image, an avatar. It fills 56% of the tile (`--throughline-icon`). */ icon: React.ReactNode; /** Which line it rides, by index. Items without one are dealt round the lines in order. */ line?: number; /** Makes the tile a link, reachable with Tab. */ href?: string; /** Classes for this tile, e.g. a solid dark tile instead of glass. */ className?: string; } export interface ThroughlineProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> { /** The dotted lines, in % of the box. Tiles ride from `from` to `to`. */ lines: ThroughlineLine[]; /** The tiles. Each line spaces its own tiles evenly. */ items: ThroughlineItem[]; /** Seconds a tile takes to ride a line as long as the box is wide. Longer lines take longer, so every tile keeps one pace. */ duration?: number; /** Brings every line to a stop (and back up to speed when it turns false). */ paused?: boolean; /** Seconds a held line takes to slow to a stop. */ brakeDuration?: number; /** Seconds a line takes to get back up to speed once it's let go. */ releaseDuration?: number; /** Draws the small square stations at each end of a line. */ stations?: boolean; /** What the lines thread through: a headline, a card, a whole hero. It sets the box's size. */ children?: React.ReactNode; } const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); const fract = (v: number) => v - Math.floor(v); /** A repeatable 0–1 number per index, so tilts and offsets survive a reload. */ const rand = (i: number) => fract(Math.sin(i * 127.1 + 311.7) * 43758.5453); const REDUCED_QUERY = "(prefers-reduced-motion: reduce)"; const subscribeReduced = (onChange: () => void) => { const mq = window.matchMedia(REDUCED_QUERY); mq.addEventListener("change", onChange); return () => mq.removeEventListener("change", onChange); }; /** Live, and false on the server and in the hydration render, so markup never mismatches. */ function usePrefersReducedMotion() { return React.useSyncExternalStore( subscribeReduced, () => window.matchMedia(REDUCED_QUERY).matches, () => false, ); } /* ── Geometry ───────────────────────────────────────────── */ type Box = { w: number; h: number }; type Track = { len: number; at: (p: number) => [number, number] }; /** The quadratic's control point, in % of the box. */ function bendPoint({ from, to, bend = 0 }: ThroughlineLine): ThroughlinePoint { const [x0, y0] = from; const [x1, y1] = to; return [(x0 + x1) / 2 - (y1 - y0) * bend, (y0 + y1) / 2 + (x1 - x0) * bend]; } /** A line in pixels, walked by distance so tiles keep one speed round the bend. */ function makeTrack(line: ThroughlineLine, { w, h }: Box): Track { const [cx, cy] = bendPoint(line); const x0 = (line.from[0] * w) / 100; const y0 = (line.from[1] * h) / 100; const x1 = (line.to[0] * w) / 100; const y1 = (line.to[1] * h) / 100; const qx = (cx * w) / 100; const qy = (cy * h) / 100; const point = (t: number): [number, number] => { const u = 1 - t; return [u * u * x0 + 2 * u * t * qx + t * t * x1, u * u * y0 + 2 * u * t * qy + t * t * y1]; }; const steps = 240; const acc = new Float64Array(steps + 1); let [px, py] = point(0); for (let i = 1; i <= steps; i++) { const [x, y] = point(i / steps); acc[i] = acc[i - 1] + Math.hypot(x - px, y - py); px = x; py = y; } const len = acc[steps]; const at = (p: number) => { const target = clamp01(p) * len; let lo = 0; let hi = steps; while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (acc[mid] < target) lo = mid; else hi = mid; } const span = acc[hi] - acc[lo] || 1; return point((lo + (target - acc[lo]) / span) / steps); }; return { len, at }; } /** Grows out of a station fast and settles; dives back in the same way. */ function scaleAt(p: number) { const e = Math.min(p, 1 - p) / RIDE.launch; if (e >= 1) return 1; return RIDE.far + (1 - RIDE.far) * (1 - (1 - e) ** 3); } type Pose = { x: number; y: number; rotate: number; scale: number }; const poseCss = ({ x, y, rotate, scale }: Pose) => `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) rotate(${rotate.toFixed(2)}deg) scale(${scale.toFixed(4)})`; /* ── Riders ─────────────────────────────────────────────── */ type Seat = { line: number; phase: number; tilt: number; sway: number }; /** Deals items onto lines and spaces each line's tiles evenly, from a different start per line. */ function seat(items: ThroughlineItem[], lineCount: number): Seat[] { if (lineCount === 0) return []; const lineOf = items.map((item, i) => ((item.line ?? i) % lineCount + lineCount) % lineCount); const counts = new Array<number>(lineCount).fill(0); const order = lineOf.map((l) => counts[l]++); return lineOf.map((line, i) => ({ line, phase: fract(order[i] / counts[line] + rand(line + 1)), tilt: (rand(i * 7 + 3) * 2 - 1) * RIDE.tilt, sway: rand(i * 13 + 5) > 0.5 ? 1 : -1, })); } function poseAt(track: Track, s: Seat, p: number): Pose { const [x, y] = track.at(p); return { x, y, rotate: s.tilt + RIDE.sway * s.sway * (p - 0.5), scale: scaleAt(p) }; } /** * Where the tiles sit when nothing rides: full size, evenly spaced along the * middle of each line, with each line's spacing slid to keep its tiles as far * as it can from tiles already placed, so they don't pile up where lines cross. */ function restingPoints(seats: Seat[], tracks: Track[]) { const points = new Array<number>(seats.length).fill(0.5); const placed: [number, number][] = []; tracks.forEach((track, line) => { const mine = seats.map((s, i) => (s.line === line ? i : -1)).filter((i) => i >= 0); if (mine.length === 0) return; let best: number[] = []; let bestGap = -1; for (let k = 0; k < 24; k++) { const ps = mine.map((_, j) => 0.14 + 0.72 * fract(k / 24 / mine.length + j / mine.length)); let gap = Infinity; for (const p of ps) { const [x, y] = track.at(p); for (const [px, py] of placed) gap = Math.min(gap, Math.hypot(x - px, y - py)); } if (gap > bestGap) { bestGap = gap; best = ps; } } mine.forEach((i, j) => { points[i] = best[j]; placed.push(track.at(best[j])); }); }); return points; } type Rider = { index: number; seat: Seat; track: Track; ms: number; // one ride, in ms move: Animation; fade: Animation; }; type Lane = { riders: Rider[]; u: number; // throttle, 0–1; speed is u² raf: number; last: number; native: boolean; // running on its own (off the main thread) rather than stepped }; const timeOf = (a: Animation) => Number(a.currentTime ?? 0); /* ── Throughline ────────────────────────────────────────── */ type Held = { index: number; x: number; y: number; rotate: number; scale: number }; export function Throughline({ lines, items, duration = TIMING.duration, paused = false, brakeDuration = TIMING.brakeDuration, releaseDuration = TIMING.releaseDuration, stations = true, className, children, style, ...props }: ThroughlineProps) { const reduced = usePrefersReducedMotion(); const rootRef = React.useRef<HTMLDivElement>(null); const outerRefs = React.useRef<(HTMLElement | null)[]>([]); const glassRefs = React.useRef<(HTMLElement | null)[]>([]); const [box, setBox] = React.useState<Box | null>(null); const [held, setHeld] = React.useState<Held | null>(null); const [shownLabel, setShownLabel] = React.useState<Held | null>(null); const seats = React.useMemo(() => seat(items, lines.length), [items, lines.length]); const tracks = React.useMemo(() => (box ? lines.map((l) => makeTrack(l, box)) : null), [lines, box]); const rest = React.useMemo(() => (reduced && tracks ? restingPoints(seats, tracks) : null), [reduced, seats, tracks]); // The controller lives across renders; handlers reach it through this ref. const ctl = React.useRef<{ hold: (i: number) => Held | null; letGo: (i: number) => void; drive: () => void; } | null>(null); const holds = React.useRef(new Set<number>()); const pausedRef = React.useRef(paused); const brakeRef = React.useRef({ brake: brakeDuration, release: releaseDuration }); const fracs = React.useRef(new Map<number, number>()); // Size of the box, in pixels. React.useLayoutEffect(() => { const root = rootRef.current; if (!root) return; const measure = () => { const w = root.clientWidth; const h = root.clientHeight; setBox((b) => (b && Math.abs(b.w - w) < 0.5 && Math.abs(b.h - h) < 0.5 ? b : { w, h })); }; measure(); const ro = new ResizeObserver(measure); ro.observe(root); return () => ro.disconnect(); }, []); // A stable key for the ride geometry, so inline `lines` / `items` arrays don't rebuild every render. const geometry = box && tracks ? `${box.w}x${box.h}|${JSON.stringify(lines)}|${seats.map((s) => s.line).join()}` : ""; // Build the rides: one transform animation per tile, one opacity animation on its glass. React.useEffect(() => { const root = rootRef.current; if (!root || !box || !tracks || reduced) { ctl.current = null; return; } const saved = fracs.current; const lanes: Lane[] = lines.map(() => ({ riders: [], u: 1, raf: 0, last: 0, native: true })); const riders: (Rider | null)[] = seats.map((s, i) => { const outer = outerRefs.current[i]; const glass = glassRefs.current[i]; const track = tracks[s.line]; if (!outer || !glass || !track || track.len < 1) return null; const ms = ((duration * track.len) / box.w) * 1000; const frames: Keyframe[] = []; for (let k = 0; k <= RIDE.samples; k++) { const p = k / RIDE.samples; frames.push({ offset: p, transform: poseCss(poseAt(track, s, p)) }); } const timing: KeyframeAnimationOptions = { duration: ms, iterations: Infinity, easing: TIMING.ride }; const move = outer.animate(frames, timing); const fade = glass.animate( [ { offset: 0, opacity: 0 }, { offset: RIDE.fade, opacity: 1 }, { offset: 1 - RIDE.fade, opacity: 1 }, { offset: 1, opacity: 0 }, ], timing, ); move.currentTime = fade.currentTime = (saved.get(i) ?? s.phase) * ms; const rider = { index: i, seat: s, track, ms, move, fade }; lanes[s.line].riders.push(rider); return rider; }); let visible = true; const want = (lane: Lane) => !pausedRef.current && lane.riders.every((r) => !holds.current.has(r.index)) ? 1 : 0; const setNative = (lane: Lane, on: boolean) => { lane.native = on; for (const r of lane.riders) { if (on) { r.move.play(); r.fade.play(); } else { r.move.pause(); r.fade.pause(); } } }; // While a line brakes or picks up speed, its tiles are stepped by hand. const step = (lane: Lane) => (now: number) => { const dt = Math.min(0.05, (now - lane.last) / 1000); lane.last = now; const target = want(lane); lane.u = target > lane.u ? Math.min(target, lane.u + dt / brakeRef.current.release) : Math.max(target, lane.u - dt / brakeRef.current.brake); const ahead = dt * lane.u * lane.u * 1000; for (const r of lane.riders) { r.move.currentTime = timeOf(r.move) + ahead; r.fade.currentTime = timeOf(r.fade) + ahead; } if (lane.u === target) { lane.raf = 0; if (target === 1) setNative(lane, true); return; } lane.raf = requestAnimationFrame(step(lane)); }; const drive = () => { if (!visible) return; for (const lane of lanes) { if (lane.raf || lane.u === want(lane)) continue; if (lane.native) setNative(lane, false); lane.last = performance.now(); lane.raf = requestAnimationFrame(step(lane)); } }; const sleep = (asleep: boolean) => { visible = !asleep; for (const lane of lanes) { cancelAnimationFrame(lane.raf); lane.raf = 0; lane.u = asleep ? lane.u : want(lane); setNative(lane, !asleep && lane.u === 1); } }; lanes.forEach((lane) => { lane.u = want(lane); if (lane.u === 0) setNative(lane, false); }); const hold = (i: number): Held | null => { const r = riders[i]; if (!r) return null; const lane = lanes[r.seat.line]; // Where it will come to rest: the brake covers u³ · brake / 3 of the ride. const moving = visible && (lane.native || lane.raf !== 0); const ahead = moving ? ((lane.u ** 3 * brakeRef.current.brake) / 3) * 1000 : 0; const p = fract((timeOf(r.move) + ahead) / r.ms); holds.current.add(i); drive(); return { index: i, ...poseAt(r.track, r.seat, p) }; }; const letGo = (i: number) => { holds.current.delete(i); drive(); }; ctl.current = { hold, letGo, drive }; const io = new IntersectionObserver(([entry]) => sleep(!entry.isIntersecting)); io.observe(root); return () => { io.disconnect(); ctl.current = null; riders.forEach((r, i) => { if (!r) return; saved.set(i, fract(timeOf(r.move) / r.ms)); r.move.cancel(); r.fade.cancel(); }); lanes.forEach((lane) => cancelAnimationFrame(lane.raf)); }; // `geometry` stands in for box, tracks, lines and seats. // eslint-disable-next-line react-hooks/exhaustive-deps }, [geometry, duration, reduced]); React.useEffect(() => { pausedRef.current = paused; ctl.current?.drive(); }, [paused]); React.useEffect(() => { brakeRef.current = { brake: Math.max(0.01, brakeDuration), release: Math.max(0.01, releaseDuration) }; }, [brakeDuration, releaseDuration]); // Hold and let go, with an intent delay on the way in and a linger on the way out. const engaged = React.useRef(new Set<number>()); const intents = React.useRef(new Map<number, number>()); const lingers = React.useRef(new Map<number, number>()); const touchHeld = React.useRef<number | null>(null); const cancel = (timers: Map<number, number>, i: number) => { window.clearTimeout(timers.get(i)); timers.delete(i); }; const engage = React.useCallback( (i: number) => { cancel(intents.current, i); cancel(lingers.current, i); engaged.current.add(i); let pose: Held | null = null; if (ctl.current) { pose = ctl.current.hold(i); } else if (tracks && rest && seats[i]) { // Reduced motion: the tile is already still. const s = seats[i]; pose = { index: i, ...poseAt(tracks[s.line], s, rest[i]), scale: 1 }; } if (pose) { setHeld(pose); setShownLabel(pose); } }, [tracks, seats, rest], ); const disengage = React.useCallback((i: number) => { cancel(intents.current, i); if (!engaged.current.delete(i)) return; setHeld((h) => (h?.index === i ? null : h)); cancel(lingers.current, i); lingers.current.set( i, window.setTimeout(() => { lingers.current.delete(i); ctl.current?.letGo(i); }, TIMING.linger), ); }, []); // A tap elsewhere lets go of a tile held by touch. React.useEffect(() => { const onDown = (e: PointerEvent) => { const i = touchHeld.current; if (i === null) return; const tile = outerRefs.current[i]; if (tile && e.target instanceof Node && tile.contains(e.target)) return; touchHeld.current = null; disengage(i); }; document.addEventListener("pointerdown", onDown); return () => document.removeEventListener("pointerdown", onDown); }, [disengage]); React.useEffect(() => { const pending = [intents.current, lingers.current]; return () => pending.forEach((timers) => timers.forEach((id) => window.clearTimeout(id))); }, []); const ready = box !== null; const plain = items.filter((item) => !item.href); const renderLayer = (behind: boolean) => { const layerLines = lines.map((l, i) => ({ l, i })).filter(({ l }) => !!l.behind === behind); if (layerLines.length === 0) return null; const onLayer = new Set(layerLines.map(({ i }) => i)); return ( <div className={cn("pointer-events-none absolute inset-0", behind ? "z-0" : "z-20")} data-throughline-layer={behind ? "behind" : "front"} > {box && ( <svg aria-hidden viewBox={`0 0 ${box.w} ${box.h}`} className="absolute inset-0 size-full overflow-visible" fill="none" > {layerLines.map(({ l, i }) => { const [cx, cy] = bendPoint(l); const sx = box.w / 100; const sy = box.h / 100; return ( <path key={i} d={`M ${l.from[0] * sx} ${l.from[1] * sy} Q ${cx * sx} ${cy * sy} ${l.to[0] * sx} ${l.to[1] * sy}`} stroke="var(--throughline-line)" strokeWidth={1.75} strokeLinecap="round" strokeDasharray="0 6" /> ); })} </svg> )} {items.map((item, i) => { const s = seats[i]; if (!s || !onLayer.has(s.line)) return null; const isHeld = held?.index === i; const still = tracks && rest ? poseAt(tracks[s.line], s, rest[i]) : null; const Tag = item.href ? "a" : "span"; return ( <span key={i} ref={(el) => { outerRefs.current[i] = el; }} className="absolute left-0 top-0 size-0 will-change-transform" style={still ? { transform: poseCss({ ...still, scale: 1 }) } : undefined} > <span className={cn( `absolute size-(--throughline-tile) transition-transform ${TIMING.face} will-change-transform motion-reduce:transition-none`, "left-[calc(var(--throughline-tile)/-2)] top-[calc(var(--throughline-tile)/-2)]", )} style={isHeld ? { transform: `rotate(${(-held.rotate).toFixed(2)}deg) scale(${RIDE.lift})` } : undefined} > <Tag ref={(el: HTMLElement | null) => { glassRefs.current[i] = el; }} {...(item.href ? { href: item.href, "aria-label": item.label } : { "aria-hidden": true })} className={cn( "pointer-events-auto flex size-full items-center justify-center rounded-[28%]", "border border-white/70 bg-white/35 backdrop-blur-[5px] backdrop-saturate-[1.8]", "shadow-[0_14px_30px_-14px_rgb(20_16_40/0.45),inset_0_1px_0_rgb(255_255_255/0.75)]", "dark:border-white/15 dark:bg-white/[0.07] dark:shadow-[0_14px_30px_-14px_rgb(0_0_0/0.8),inset_0_1px_0_rgb(255_255_255/0.14)]", "focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current", `transition-[filter] ${TIMING.enter.fade}`, ready ? "filter-none" : "[filter:opacity(0)]", item.className, )} style={{ transitionDelay: `${i * TIMING.enter.stagger}ms` }} onPointerMove={(e: React.PointerEvent) => { if (e.pointerType !== "mouse" || engaged.current.has(i) || intents.current.has(i)) return; // Back on it before the line moved off: hold again at once. if (lingers.current.has(i)) engage(i); else intents.current.set(i, window.setTimeout(() => engage(i), TIMING.intent)); }} onPointerLeave={(e: React.PointerEvent) => { if (e.pointerType === "mouse") disengage(i); }} onPointerDown={(e: React.PointerEvent) => { if (e.pointerType === "mouse") return; if (touchHeld.current === i) { touchHeld.current = null; disengage(i); return; } if (touchHeld.current !== null) disengage(touchHeld.current); touchHeld.current = i; engage(i); }} onFocus={item.href ? () => engage(i) : undefined} onBlur={item.href ? () => disengage(i) : undefined} > <span className="pointer-events-none flex size-(--throughline-icon) items-center justify-center *:size-full *:object-contain"> {item.icon} </span> </Tag> </span> </span> ); })} {stations && layerLines.map(({ l, i }) => [l.from, l.to].map(([x, y], end) => ( <span key={`${i}-${end}`} aria-hidden className="absolute size-[7px] -translate-x-1/2 -translate-y-1/2 rounded-[2px] bg-(--throughline-station) shadow-[0_0_0_3px_var(--throughline-station-ring)]" style={{ left: `${x}%`, top: `${y}%` }} /> )), )} </div> ); }; const label = shownLabel ? items[shownLabel.index]?.label : undefined; return ( <div ref={rootRef} className={cn( "@container/throughline relative isolate overflow-x-clip", "[--throughline-tile:clamp(2rem,6.5cqi,4.5rem)] [--throughline-icon:56%]", "[--throughline-line:color-mix(in_oklab,currentColor_42%,transparent)]", "[--throughline-station:currentColor]", "[--throughline-station-ring:color-mix(in_oklab,currentColor_14%,transparent)]", className, )} style={style} {...props} > {renderLayer(true)} <div className="relative z-10">{children}</div> {renderLayer(false)} <div aria-hidden className="pointer-events-none absolute left-0 top-0 z-30" style={ shownLabel ? { transform: `translate(${shownLabel.x.toFixed(1)}px, ${shownLabel.y.toFixed(1)}px)`, ["--throughline-held" as string]: shownLabel.scale * RIDE.lift, } : undefined } > <span className={cn( "absolute left-0 top-[calc(var(--throughline-tile)*var(--throughline-held,1)/2+10px)] -translate-x-1/2 whitespace-nowrap rounded-full px-2.5 py-1.5 text-xs font-medium leading-none", "bg-zinc-950 text-white shadow-[0_6px_16px_-6px_rgb(0_0_0/0.4)] dark:bg-white dark:text-zinc-950", `transition-[opacity,translate] ${TIMING.label} will-change-[opacity,translate]`, held ? "translate-y-0 opacity-100" : "translate-y-1.5 opacity-0", )} onTransitionEnd={() => { if (!held) setShownLabel(null); }} > {label} </span> </div> {plain.length > 0 && ( <ul className="sr-only"> {plain.map((item, i) => ( <li key={i}>{item.label}</li> ))} </ul> )} </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Throughline. Each one installs whole, with the component included.
Pipeline
An AI inbox in the middle of six lines: raw messages ride in and dive under the card, sorted tickets, replies and leads ride out the other side.
How Sift works
import * as React from "react";
import { Throughline, type ThroughlineItem, type ThroughlineLine } from "@/components/ui/throughline";
/** An ink glyph on plain glass: something raw, on its way in. */
function Raw({ children }: { children: React.ReactNode }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className="text-zinc-900 dark:text-white">
{children}
</svg>
);
}
/** A colored chip with a white glyph: something sorted, on its way out. */
function Sorted({ bg, children }: { bg: string; children: React.ReactNode }) {
return (
<span className="flex items-center justify-center rounded-[26%]" style={{ background: bg }}>
<svg viewBox="0 0 24 24" className="size-[62%]" fill="none" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
</span>
);
}
// Everything meets under the card at [50, 50]; the card hides both stations there.
const LINES: ThroughlineLine[] = [
{ from: [2, 14], to: [50, 50], bend: -0.12, behind: true },
{ from: [1, 54], to: [50, 50], bend: 0.04, behind: true },
{ from: [3, 92], to: [50, 50], bend: 0.12, behind: true },
{ from: [50, 50], to: [97, 12], bend: -0.12, behind: true },
{ from: [50, 50], to: [99, 48], bend: 0.04, behind: true },
{ from: [50, 50], to: [97, 90], bend: 0.12, behind: true },
];
const ITEMS: ThroughlineItem[] = [
{
label: "Email · “Refund for order 4471?”",
line: 0,
icon: (
<Raw>
<rect x="3.5" y="5.5" width="17" height="13" rx="2" />
<path d="m4 7 8 6 8-6" />
</Raw>
),
},
{
label: "Chat · “Can I add three seats?”",
line: 1,
icon: (
<Raw>
<path d="M5 18.5V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H8Z" />
<path d="M9 10h6M9 13h3.5" />
</Raw>
),
},
{
label: "Voicemail · 4 min, Spanish",
line: 2,
icon: (
<Raw>
<circle cx="7" cy="13" r="3" />
<circle cx="17" cy="13" r="3" />
<path d="M7 16h10" />
</Raw>
),
},
{
label: "Form · Enterprise demo request",
line: 0,
icon: (
<Raw>
<rect x="5" y="3.5" width="14" height="17" rx="2" />
<path d="M9 8.5h6M9 12h6M9 15.5h3" />
</Raw>
),
},
{
label: "Review · 2★, “checkout froze”",
line: 2,
icon: (
<Raw>
<path d="m12 4 2.3 4.8 5.2.7-3.8 3.6.9 5.2L12 15.8l-4.6 2.5.9-5.2-3.8-3.6 5.2-.7Z" />
</Raw>
),
},
{
label: "Slack thread · 14 replies",
line: 1,
icon: (
<Raw>
<path d="M8 4v16M16 4v16M4 8h16M4 16h16" />
</Raw>
),
},
{
label: "Ticket · Billing, P2",
line: 3,
icon: (
<Sorted bg="#2f3cff">
<path d="M4 9V6.5h16V9a3 3 0 0 0 0 6v2.5H4V15a3 3 0 0 0 0-6Z" />
</Sorted>
),
},
{
label: "Reply drafted · ready to send",
line: 4,
icon: (
<Sorted bg="#16a34a">
<path d="m5.5 12.5 4 4 9-9" />
</Sorted>
),
},
{
label: "Lead · sent to Ada in Sales",
line: 5,
icon: (
<Sorted bg="#ff5a1f">
<circle cx="12" cy="9" r="3.2" />
<path d="M5.5 19c1.2-3 3.7-4.5 6.5-4.5s5.3 1.5 6.5 4.5" />
</Sorted>
),
},
{
label: "Bug filed · CHK-212, with repro",
line: 4,
icon: (
<Sorted bg="#7b5cff">
<rect x="7.5" y="7" width="9" height="12" rx="4.5" />
<path d="M12 11v4M4.5 12h3M16.5 12h3M5.5 7.5l2.2 1.5M18.5 7.5l-2.2 1.5" />
</Sorted>
),
},
{
label: "Refund queued · $64.00",
line: 5,
icon: (
<Sorted bg="#2f3cff">
<path d="M15.5 8.5c-.6-1.2-1.9-2-3.5-2-2 0-3.5 1.1-3.5 2.7 0 3.6 7 1.9 7 5.6 0 1.6-1.5 2.7-3.5 2.7-1.6 0-2.9-.8-3.5-2M12 4.5v2M12 17.5v2" />
</Sorted>
),
},
{
label: "Alert · outage mentioned twice",
line: 3,
icon: (
<Sorted bg="#0b0b0f">
<path d="M12 8v5M12 16.5v.01" />
<path d="M10.3 4.6 3.6 17a2 2 0 0 0 1.7 3h13.4a2 2 0 0 0 1.7-3L13.7 4.6a2 2 0 0 0-3.4 0Z" />
</Sorted>
),
},
];
const QUEUES = [
{ name: "Billing", count: 412, share: 0.82, bar: "bg-[#2f3cff] dark:bg-[#7d86ff]" },
{ name: "Product bugs", count: 197, share: 0.46, bar: "bg-zinc-900 dark:bg-white/80" },
{ name: "Sales", count: 88, share: 0.24, bar: "bg-[#ff5a1f]" },
];
export function ThroughlinePipelineDemo() {
return (
<section className="mx-auto w-full max-w-5xl overflow-hidden rounded-[28px] bg-zinc-50 pt-12 text-zinc-950 dark:bg-[#0b0b10] dark:text-white">
<div className="px-6 text-center">
<p className="text-[13px] font-medium text-[#2f3cff] dark:text-[#7d86ff]">How Sift works</p>
<h2 className="mx-auto mt-3 max-w-[22rem] text-balance text-[clamp(1.75rem,4cqi,2.5rem)] font-semibold leading-[1.08] tracking-[-0.035em] sm:max-w-none">
Everything comes in. Only sorted work goes out.
</h2>
</div>
<Throughline lines={LINES} items={ITEMS} duration={22} className="mt-2 text-[#2f3cff] dark:text-[#7d86ff]">
<div className="flex justify-center px-6 py-[max(4.5rem,9cqi)]">
<div className="w-[min(19rem,70cqi)] rounded-[22px] border text-zinc-950 dark:text-white border-zinc-950/[0.07] bg-white p-5 shadow-[0_24px_48px_-24px_rgb(20_20_60/0.35)] dark:border-white/10 dark:bg-zinc-900 dark:shadow-[0_24px_48px_-24px_rgb(0_0_0/0.9)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex size-7 items-center justify-center rounded-[9px] bg-[#2f3cff]">
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="#fff" strokeWidth="2.4" strokeLinecap="round">
<path d="M5 7h14M8 12h8M11 17h2" />
</svg>
</span>
<span className="text-sm font-semibold tracking-[-0.01em]">Sift</span>
</div>
<span className="flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-500" />
Live
</span>
</div>
<p className="mt-5 text-[2.5rem] font-semibold leading-none tracking-[-0.04em] tabular-nums">1,284</p>
<p className="mt-1.5 text-xs text-zinc-500 dark:text-zinc-400">messages triaged today, 3.1s each</p>
<ul className="mt-5 space-y-3">
{QUEUES.map((q) => (
<li key={q.name}>
<div className="flex items-baseline justify-between text-xs">
<span className="text-zinc-600 dark:text-zinc-300">{q.name}</span>
<span className="font-medium tabular-nums">{q.count}</span>
</div>
<div className="mt-1.5 h-1.5 rounded-full bg-zinc-100 dark:bg-white/10">
<div className={`h-full rounded-full ${q.bar}`} style={{ width: `${q.share * 100}%` }} />
</div>
</li>
))}
</ul>
</div>
</div>
</Throughline>
</section>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Throughline: Glass tiles that ride dotted lines through your headline. Each tile slides out from under a small station, grows as it travels, turns a little on the way, and dives into the far station, and a tile passing over a colored letter picks up its color through the glass. Rest the pointer on one and its whole line comes to a stop, like a cable car line, while the tile turns upright and shows its label. Lines can run behind the content as well as over it. This demo, Pipeline: An AI inbox in the middle of six lines: raw messages ride in and dive under the card, sorted tickets, replies and leads ride out the other side. 1. Install it: npx shadcn@latest add @keyframeui/throughline-pipeline-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/throughline-pipeline-demo.json instead.) It adds the demo, components/throughline-pipeline-demo.tsx, and the component, components/ui/throughline.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Throughline> on its own. 3. These props set its feel. Change them before you change the file: - duration (default 26): Seconds a tile takes to ride a line as long as the box is wide. Longer lines take longer, so every tile keeps one pace. - brakeDuration (default 0.3): How long a line takes to slow to a stop when one of its tiles is held, in seconds. Longer and the tile glides on past the pointer before it stops. - releaseDuration (default 0.6): How long a line takes to get back up to speed once it's let go, in seconds. - stations (default true): Draws the small square stations at each end of a line. 4. Don't change, in components/ui/throughline.tsx: - Each ride is a Web Animation on the tile's transform, plus one on its glass's opacity, run linearly through `RIDE.samples` poses so tiles keep one pace off the main thread. - Braking and releasing step a line's animations by hand through `currentTime`, one rAF per line with speed as u²; a line goes back to running on its own only at full speed. - A hold starts from `pointermove` after the hover intent and lets go after the linger, so a sweep across, or a tile drifting under a still pointer, never stops a line. - Off screen every line pauses where it is, and a rebuild (a resize, a new `duration`) puts each tile back at the place saved in `fracs`, so nothing jumps to a start. - Reduced motion builds no rides: tiles sit full size at their `restingPoints`, and hover, focus and tap still show labels. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/throughline.md
Community
Members' faces on two arcs around a critique circle's sign-up, at night. Hold one to see who and where.
"use client";
import * as React from "react";
import { Throughline, type ThroughlineItem, type ThroughlineLine } from "@/components/ui/throughline";
const face = (id: string) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&crop=faces&w=160&h=160&q=80`;
const MEMBERS = [
{ name: "Inés Duarte", city: "Porto", photo: "1438761681033-6461ffad8d80" },
{ name: "Rafa Ortiz", city: "Mexico City", photo: "1507003211169-0a1dd7228f2d" },
{ name: "Mei Lin", city: "Taipei", photo: "1534528741775-53994a69daeb" },
{ name: "Luca Ferri", city: "Turin", photo: "1500648767791-00dcc994a43e" },
{ name: "Adaeze Obi", city: "Lagos", photo: "1531123897727-8f129e1688ce" },
{ name: "Jonas Berg", city: "Oslo", photo: "1539571696357-5a69c17a67c6" },
{ name: "Sofia Marin", city: "Bucharest", photo: "1494790108377-be9c29b29330" },
{ name: "Tom Hale", city: "Leeds", photo: "1506794778202-cad84cf45f1d" },
];
// Two long arcs, one over the copy and one under it, so a face never sits on a word.
const LINES: ThroughlineLine[] = [
{ from: [3, 34], to: [97, 8], bend: -0.2 },
{ from: [97, 70], to: [3, 94], bend: -0.2 },
];
const ITEMS: ThroughlineItem[] = MEMBERS.map((m) => ({
label: `${m.name} · ${m.city}`,
icon: <span className="block rounded-full bg-zinc-800 bg-cover bg-center" style={{ backgroundImage: `url(${face(m.photo)})` }} />,
className:
"[--throughline-icon:100%] rounded-full border-0 bg-transparent p-[3px] backdrop-blur-none shadow-[0_0_0_1.5px_rgb(198_255_61/0.9),0_14px_28px_-12px_rgb(0_0_0/0.8)] dark:bg-transparent dark:shadow-[0_0_0_1.5px_rgb(198_255_61/0.9),0_14px_28px_-12px_rgb(0_0_0/0.8)]",
}));
export function ThroughlineCommunityDemo() {
const [sent, setSent] = React.useState(false);
return (
<section className="w-full overflow-hidden rounded-[28px] bg-[#0b0b12] text-white">
<Throughline lines={LINES} items={ITEMS} duration={30} className="text-[#c6ff3d]">
<div className="flex flex-col items-center px-6 py-[max(7rem,15cqi)] text-center text-white">
<p className="rounded-full border border-white/10 px-3 py-1 text-xs font-medium text-zinc-300">
Kiln · a critique circle for product designers
</p>
<h2 className="mt-5 max-w-[30rem] text-balance text-[clamp(1.9rem,5cqi,3.25rem)] font-semibold leading-[1.04] tracking-[-0.04em]">
Post one shot a week. Get three honest notes back.
</h2>
<p className="mt-4 max-w-[24rem] text-pretty text-[15px] leading-relaxed text-zinc-400">
2,431 designers in 64 cities. No likes, no followers, just notes by Sunday.
</p>
<form
className="mt-7 flex w-full max-w-[22rem] items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.06] p-1.5 transition-colors duration-150 focus-within:border-[#c6ff3d]/60"
onSubmit={(e) => {
e.preventDefault();
setSent(true);
}}
>
<label htmlFor="kiln-email" className="sr-only">
Email
</label>
<input
id="kiln-email"
type="email"
required
placeholder="you@studio.com"
className="h-10 min-w-0 flex-1 bg-transparent px-3.5 text-sm text-white outline-none placeholder:text-zinc-500"
/>
<button
type="submit"
className="h-10 shrink-0 rounded-full bg-[#c6ff3d] px-4 text-sm font-semibold text-zinc-950 transition-transform duration-150 ease-out active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6ff3d]"
>
{sent ? "You're in" : "Join the circle"}
</button>
</form>
</div>
</Throughline>
</section>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Throughline: Glass tiles that ride dotted lines through your headline. Each tile slides out from under a small station, grows as it travels, turns a little on the way, and dives into the far station, and a tile passing over a colored letter picks up its color through the glass. Rest the pointer on one and its whole line comes to a stop, like a cable car line, while the tile turns upright and shows its label. Lines can run behind the content as well as over it. This demo, Community: Members' faces on two arcs around a critique circle's sign-up, at night. Hold one to see who and where. 1. Install it: npx shadcn@latest add @keyframeui/throughline-community-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/throughline-community-demo.json instead.) It adds the demo, components/throughline-community-demo.tsx, and the component, components/ui/throughline.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Throughline> on its own. 3. These props set its feel. Change them before you change the file: - duration (default 26): Seconds a tile takes to ride a line as long as the box is wide. Longer lines take longer, so every tile keeps one pace. - brakeDuration (default 0.3): How long a line takes to slow to a stop when one of its tiles is held, in seconds. Longer and the tile glides on past the pointer before it stops. - releaseDuration (default 0.6): How long a line takes to get back up to speed once it's let go, in seconds. - stations (default true): Draws the small square stations at each end of a line. 4. Don't change, in components/ui/throughline.tsx: - Each ride is a Web Animation on the tile's transform, plus one on its glass's opacity, run linearly through `RIDE.samples` poses so tiles keep one pace off the main thread. - Braking and releasing step a line's animations by hand through `currentTime`, one rAF per line with speed as u²; a line goes back to running on its own only at full speed. - A hold starts from `pointermove` after the hover intent and lets go after the linger, so a sweep across, or a tile drifting under a still pointer, never stops a line. - Off screen every line pauses where it is, and a rebuild (a resize, a new `duration`) puts each tile back at the place saved in `fracs`, so nothing jumps to a start. - Reduced motion builds no rides: tiles sit full size at their `restingPoints`, and hover, focus and tap still show labels. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/throughline.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
lines Required | ThroughlineLine[] | None | The dotted lines, in % of the box: { from: [x, y], to: [x, y], bend?, behind? }. Tiles ride from from to to. bend bows the line by that share of its length, positive to the right of its direction (down, for a line running left to right). behind runs the line and its tiles under the content. |
items Required | ThroughlineItem[] | None | The tiles: { label, icon, line?, href?, className? }. icon is what sits in the tile (an svg, a logo, an avatar) and label is shown under it when it's held and read by screen readers. Items without a line are dealt round the lines in order, and each line spaces its own tiles evenly. href makes a tile a link you can Tab to; className restyles one tile. |
duration | number | 26 | Seconds a tile takes to ride a line as long as the box is wide. Longer lines take longer, so every tile keeps one pace. |
paused | boolean | false | Brings every line to a stop, and back up to speed when it turns false. Wire it to a pause button for pages that need one. |
brakeDuration | number | 0.3 | How long a line takes to slow to a stop when one of its tiles is held, in seconds. Longer and the tile glides on past the pointer before it stops. |
releaseDuration | number | 0.6 | How long a line takes to get back up to speed once it's let go, in seconds. |
stations | boolean | true | Draws the small square stations at each end of a line. |
children | React.ReactNode | None | What the lines thread through: a headline, a card, a whole section. It sets the size of the box the lines are drawn in. |
className | string | None | Extra classes for the box. Its text color draws the lines (at 42%) and the stations, so give the content its own color. Knobs: --throughline-tile (tile size, clamp(2rem, 6.5cqi, 4.5rem)), --throughline-icon (how much of the tile the icon fills, 56%), --throughline-line and --throughline-station. |
linesRequiredType
ThroughlineLine[]The dotted lines, in % of the box:
{ from: [x, y], to: [x, y], bend?, behind? }. Tiles ride fromfromtoto.bendbows the line by that share of its length, positive to the right of its direction (down, for a line running left to right).behindruns the line and its tiles under the content.itemsRequiredType
ThroughlineItem[]The tiles:
{ label, icon, line?, href?, className? }.iconis what sits in the tile (an svg, a logo, an avatar) andlabelis shown under it when it's held and read by screen readers. Items without alineare dealt round the lines in order, and each line spaces its own tiles evenly.hrefmakes a tile a link you can Tab to;classNamerestyles one tile.durationType
numberDefault26Seconds a tile takes to ride a line as long as the box is wide. Longer lines take longer, so every tile keeps one pace.
pausedType
booleanDefaultfalseBrings every line to a stop, and back up to speed when it turns false. Wire it to a pause button for pages that need one.
brakeDurationType
numberDefault0.3How long a line takes to slow to a stop when one of its tiles is held, in seconds. Longer and the tile glides on past the pointer before it stops.
releaseDurationType
numberDefault0.6How long a line takes to get back up to speed once it's let go, in seconds.
stationsType
booleanDefaulttrueDraws the small square stations at each end of a line.
childrenType
React.ReactNodeWhat the lines thread through: a headline, a card, a whole section. It sets the size of the box the lines are drawn in.
classNameType
stringExtra classes for the box. Its text color draws the lines (at 42%) and the stations, so give the content its own color. Knobs:
--throughline-tile(tile size,clamp(2rem, 6.5cqi, 4.5rem)),--throughline-icon(how much of the tile the icon fills,56%),--throughline-lineand--throughline-station.