"use client"; /** * ChannelMix, from https://motion-brew.com/components/channel-mix * * One self contained file: the component, its animated icons and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. * * The animated icons are adapted from lucide-animated (https://lucide-animated.com), * MIT License, Copyright (c) 2024-2026 pqoqubbw. Icon shapes from Lucide * (https://lucide.dev), ISC License. Permission is hereby granted, free of * charge, to any person obtaining a copy of this software, to deal in it * without restriction, provided the above copyright notice and this * permission notice are included in all copies or substantial portions. */ import * as React from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { type ClassValue, clsx } from "clsx"; import { Info } from "lucide-react"; import { AnimatePresence, motion, type Transition, useAnimation, useInView, useReducedMotion, type Variants } from "motion/react"; import { twMerge } from "tailwind-merge"; // cn(): merges Tailwind classes, later ones win. /** Merge Tailwind classes, letting later classes win conflicts. */ function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // Animated icon: arrow-down-right const ArrowDownRightIcon = (() => { interface ArrowDownRightIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ArrowDownRightIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const HEAD_VARIANTS: Variants = { normal: { translateX: 0, translateY: 0 }, animate: { translateX: [0, -3, 0], translateY: [0, -3, 0], transition: { duration: 0.5, ease: "easeInOut", }, }, }; const SHAFT_VARIANTS: Variants = { normal: { translateX: 0, translateY: 0, scale: 1 }, animate: { translateX: [0, -3, 0], translateY: [0, -3, 0], scale: [1, 0.85, 1], originX: 1, originY: 1, transition: { duration: 0.5, ease: "easeInOut", }, }, }; const ArrowDownRightIcon = forwardRef< ArrowDownRightIconHandle, ArrowDownRightIconProps >(({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (!isControlledRef.current) controls.start("animate"); onMouseEnter?.(e); }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (!isControlledRef.current) controls.start("normal"); onMouseLeave?.(e); }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); }); ArrowDownRightIcon.displayName = "ArrowDownRightIcon"; return ArrowDownRightIcon; })(); // Animated icon: arrow-up-right const ArrowUpRightIcon = (() => { interface ArrowUpRightIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ArrowUpRightIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const ARROW_VARIANTS: Variants = { normal: { scale: 1, translateX: 0, translateY: 0, }, animate: { scale: [1, 0.85, 1], translateX: [0, -4, 0], translateY: [0, 4, 0], originX: 1, originY: 0, transition: { duration: 0.5, ease: "easeInOut", }, }, }; const ArrowUpRightIcon = forwardRef< ArrowUpRightIconHandle, ArrowUpRightIconProps >(({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (!isControlledRef.current) controls.start("animate"); onMouseEnter?.(e); }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (!isControlledRef.current) controls.start("normal"); onMouseLeave?.(e); }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); }); ArrowUpRightIcon.displayName = "ArrowUpRightIcon"; return ArrowUpRightIcon; })(); // Animated icon: chart-pie const ChartPieIcon = (() => { interface ChartPieIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ChartPieIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const PATH_VARIANTS: Variants = { normal: { translateX: 0, translateY: 0 }, animate: { translateX: 1.1, translateY: -1.1 }, }; const ChartPieIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); ChartPieIcon.displayName = "ChartPieIcon"; return ChartPieIcon; })(); // Animated icon: circle-dollar-sign const CircleDollarSignIcon = (() => { interface CircleDollarSignIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface CircleDollarSignIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const DOLLAR_MAIN_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, transition: { duration: 0.4, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], transition: { duration: 0.6, opacity: { duration: 0.1 }, }, }, }; const DOLLAR_SECONDARY_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, pathOffset: 0, transition: { delay: 0.3, duration: 0.3, opacity: { duration: 0.1, delay: 0.3 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], pathOffset: [1, 0], transition: { delay: 0.5, duration: 0.4, opacity: { duration: 0.1, delay: 0.5 }, }, }, }; const CircleDollarSignIcon = forwardRef< CircleDollarSignIconHandle, CircleDollarSignIconProps >(({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); }); CircleDollarSignIcon.displayName = "CircleDollarSignIcon"; return CircleDollarSignIcon; })(); // Animated icon: hourglass const HourglassIcon = (() => { interface HourglassIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface HourglassIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const HourglassIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); HourglassIcon.displayName = "HourglassIcon"; return HourglassIcon; })(); // Animated icon: user-round-plus const UserRoundPlusIcon = (() => { interface UserRoundPlusIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface UserRoundPlusIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const VERTICAL_BAR_VARIANTS: Variants = { normal: { opacity: 1, }, animate: { opacity: [0, 1], pathLength: [0, 1], transition: { delay: 0.3, duration: 0.2, opacity: { duration: 0.1, delay: 0.3 }, }, }, }; const HORIZONTAL_BAR_VARIANTS: Variants = { normal: { opacity: 1, }, animate: { opacity: [0, 1], pathLength: [0, 1], transition: { delay: 0.6, duration: 0.2, opacity: { duration: 0.1, delay: 0.6 }, }, }, }; const UserRoundPlusIcon = forwardRef< UserRoundPlusIconHandle, UserRoundPlusIconProps >(({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); }); UserRoundPlusIcon.displayName = "UserRoundPlusIcon"; return UserRoundPlusIcon; })(); // ChannelMix /** * ChannelMix * * Trigger: in view, then hover and press. A white marketing card: a light * gray header with an icon tile, a title and a line of copy, then a white * panel overlapping it with the * week's sessions on rolling digits and a row of slim ticks that splits them * by channel. When it scrolls into view the ticks rise in a wave from left to * right while the numbers roll up from zero. Pointing at the bar or a legend * chip lights up that channel's ticks, dims the rest, and rolls the headline * to the channel's own sessions and change. Under a hairline, a small table of * metrics: a soft pill glides between rows under the pointer, each row's * animated icon plays, and its change arrow nudges the way it points. The info * button explains the numbers in a tooltip, and Details slides its arrow out. * Reduced motion keeps every state without movement. * * Engine: motion/react (staggered scale for the ticks, spring digit columns, * layoutId pills, AnimatePresence for the headline) with animated icons from * lucide-animated (MIT), vendored in src/icons. */ export interface ChannelMixChannel { name: string; /** Sessions from this channel in the period. */ sessions: number; /** Change against the last period, in percent. */ change: number; /** Tick and dot color. Defaults follow the card's palette. */ color?: string; } export type ChannelMixIcon = "people" | "time" | "money"; export interface ChannelMixMetric { label: string; /** Shown as written; its digits roll, everything else stays put. */ value: string; /** Change against the last period, in percent. */ change: number; /** Whether a rise is good news. Default true; set false for costs and waits. */ higherIsBetter?: boolean; icon: ChannelMixIcon; } export interface ChannelMixProps { /** Title in the shell. Default `"Channel insights"`. */ title?: React.ReactNode; /** Line under the title. */ description?: React.ReactNode; /** Label over the headline number. Default `"Sessions this week"`. */ label?: string; /** What the info button explains. */ info?: string; channels?: ChannelMixChannel[]; /** Change of all sessions against the last period, in percent. Default `2.1`. */ change?: number; metrics?: ChannelMixMetric[]; /** Called by the Details button. Leave out to hide it. */ onDetails?: () => void; /** Locale for numbers. Default `"en-US"`. */ locale?: string; /** Show the gray header band with the icon, title and copy. Default true; false leaves just the chart. */ header?: boolean; className?: string; } const PALETTE = ["#4f46e5", "#38bdf8", "#34d399", "#f59e0b", "#f472b6"]; const GOOD = "#269a5a"; const BAD = "#d64545"; const TICKS = 44; const DEFAULT_CHANNELS: ChannelMixChannel[] = [ { name: "Organic search", sessions: 21480, change: 3.4 }, { name: "Social", sessions: 15920, change: 1.2 }, { name: "Direct", sessions: 10810, change: -0.8 }, ]; const DEFAULT_METRICS: ChannelMixMetric[] = [ { label: "Cost per signup", value: "$38.25", change: -4.1, higherIsBetter: false, icon: "people", }, { label: "Days to convert", value: "4.2 days", change: 3.8, higherIsBetter: false, icon: "time", }, { label: "Return on spend", value: "324%", change: 4.5, icon: "money" }, ]; const ICONS: Record< ChannelMixIcon, React.ComponentType<{ active?: boolean; className?: string }> > = { people: UserRoundPlusIcon, time: HourglassIcon, money: CircleDollarSignIcon, }; const GLIDE = [0.22, 1, 0.36, 1] as const; const ROLL: Transition = { type: "spring", stiffness: 200, damping: 26 }; const PILL: Transition = { type: "spring", stiffness: 500, damping: 40 }; const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; function Digit({ digit, still }: { digit: number; still: boolean }) { return ( {DIGITS.map((d) => ( {d} ))} ); } /** Text whose digits roll on their own columns, keyed from the right; other characters stay put. */ function RollText({ text, still }: { text: string; still: boolean }) { const chars = Array.from(text); return ( {chars.map((ch, i) => { const key = chars.length - i; return /\d/.test(ch) ? ( ) : ( {ch} ); })} ); } /** Before the card is seen, every digit reads 0, so they roll up on arrival. */ const zeroed = (text: string) => text.replace(/\d/g, "0"); function Change({ change, good, nudge, }: { change: number; good: boolean; nudge: boolean; }) { const up = change >= 0; const Arrow = up ? ArrowUpRightIcon : ArrowDownRightIcon; return ( {up ? "+" : "−"} {Math.abs(change).toFixed(1)}% ); } export const ChannelMix = React.forwardRef( function ChannelMix( { title = "Channel insights", description = "See which channels bring people in, and what each one costs, as the week goes on.", label = "Sessions this week", info = "Visits to your site this week, split by where they came from.", channels = DEFAULT_CHANNELS, change = 2.1, metrics = DEFAULT_METRICS, onDetails, locale = "en-US", header = true, className, }, ref, ) { const still = !!useReducedMotion(); const id = React.useId(); const root = React.useRef(null); React.useImperativeHandle(ref, () => root.current!); const seen = useInView(root, { once: true, amount: 0.35 }); const shown = seen || still; const [active, setActive] = React.useState(null); const [row, setRow] = React.useState(null); const [tip, setTip] = React.useState(false); const [headHover, setHeadHover] = React.useState(false); const [settled, setSettled] = React.useState(false); const format = React.useMemo(() => new Intl.NumberFormat(locale), [locale]); // After the opening wave, ticks answer the pointer without delays. React.useEffect(() => { if (!shown) return; const t = window.setTimeout(() => setSettled(true), still ? 0 : 1400); return () => window.clearTimeout(t); }, [shown, still]); const total = channels.reduce((s, c) => s + c.sessions, 0) || 1; const colorOf = (i: number) => channels[i]?.color ?? PALETTE[i % PALETTE.length]!; // Which channel each tick belongs to: its centre against the running shares. const owner = React.useMemo(() => { const edges: number[] = []; let run = 0; for (const c of channels) edges.push((run += c.sessions / total)); return Array.from({ length: TICKS }, (_, i) => { const at = (i + 0.5) / TICKS; const k = edges.findIndex((e) => at <= e); return k < 0 ? channels.length - 1 : k; }); }, [channels, total]); const focus = active === null ? null : channels[active]; const headline = focus ? format.format(focus.sessions) : format.format(total); const headChange = focus ? focus.change : change; const pickFromPointer = (e: React.PointerEvent) => { const r = e.currentTarget.getBoundingClientRect(); const i = Math.min( TICKS - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * TICKS)), ); setActive(owner[i] ?? null); }; return ( {/* Header band: icon, title and copy. Leave it out with header={false}. */} {header && (
setHeadHover(true)} onPointerLeave={() => setHeadHover(false)} >

{title}

{description}

)} {/* Headline */}
{focus && ( )} {focus ? focus.name : label} {tip && ( {info} )}
{headline} = 0} nudge={active !== null} /> vs last week
{onDetails && ( Details )}
{/* Tick bar: one slim tick per slice of the total, coloured by channel. */}
`${c.name} ${Math.round((c.sessions / total) * 100)}%`, ) .join(", ")} onPointerMove={pickFromPointer} onPointerLeave={() => setActive(null)} className="mt-6 flex h-10 cursor-crosshair items-end justify-between" > {owner.map((k, i) => { const lit = active === null || active === k; return ( ); })}
{/* Legend chips double as keyboard controls for the bar. */}
{channels.map((c, i) => { const on = active === i; return ( setActive(i)} onPointerLeave={() => setActive(null)} onFocus={() => setActive(i)} onBlur={() => setActive(null)} initial={still ? false : { opacity: 0, y: 6 }} animate={ shown ? { opacity: active === null || on ? 1 : 0.5, y: 0 } : undefined } transition={{ duration: 0.35, ease: GLIDE, delay: settled || still ? 0 : 0.6 + i * 0.06, }} className="flex h-8 cursor-default items-center gap-2 rounded-full bg-[#242423]/[0.04] pl-2.5 pr-3 text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-zinc-400" > {c.name} {Math.round((c.sessions / total) * 100)}% ); })}
{/* Metrics */}
setRow(null)} >
Metric Value Change
{metrics.map((m, i) => { const Icon = ICONS[m.icon]; const good = m.change >= 0 === (m.higherIsBetter ?? true); const on = row === i; return ( setRow(i)} onFocus={() => setRow(i)} onBlur={() => setRow(null)} initial={still ? false : { opacity: 0, y: 8 }} animate={shown ? { opacity: 1, y: 0 } : undefined} transition={{ duration: 0.4, ease: GLIDE, delay: still ? 0 : 0.7 + i * 0.07, }} className="relative grid h-11 grid-cols-[1fr_auto_80px] items-center gap-3 rounded-xl px-2 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-zinc-300" > {on && ( )} {m.label} {m.value} ); })}
); }, );