'use client' import * as React from 'react' import { cn } from '../../utils/cn' import { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine' import { useMediaQuery } from '../../hooks/ui/use-media-query' import { useResetOnPageHidden } from '../../hooks/ui/use-reset-on-page-hidden' import { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus' import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport' // ============================================================================= // Types // ============================================================================= export type MarqueeWallFadeEdge = 'top' | 'bottom' | 'left' | 'right' /** One spelling of the `fade` prop's edge-list normalization. */ function normalizeFades( fade: MarqueeWallFadeEdge | ReadonlyArray | undefined, ): ReadonlyArray { return fade ? (Array.isArray(fade) ? fade : [fade as MarqueeWallFadeEdge]) : [] } /** @internal per-instance sync membership (transform writer + driver-role * promotion callback, so a surviving member can be re-elected). */ interface MarqueeSyncMember { apply: (pos: number) => void makeDriver: () => void } /** * Pairs two (or more) instances to ONE position driver so their * tracks stay pixel-locked (the FreeTrialCTA resolve strips: a pending and a * resolved copy of the same chips, clip-path'd at the avatar line). The first * mounted instance claims the driver role and runs the shared rAF; the others * only register their `apply` transforms. If the driver unmounts while * members survive, the next member is promoted — the pair must merely be * geometry-identical while mounted together. */ export interface MarqueeSyncController { /** @internal registered members in mount order. */ members: Set /** @internal current driver member. */ driver: MarqueeSyncMember | null /** @internal last driver-written position — a promoted driver's engine * seeds from it, so re-election never snaps the pair back to 0. */ position: number } export function useMarqueeSync(): MarqueeSyncController { return React.useRef({ members: new Set(), driver: null, position: 0 }).current } // ============================================================================= // FLIP coordination — live track offsets + loop-clone helpers // ============================================================================= export interface MarqueeTrackOffset { /** Applied translation (px) along each axis. */ x: number y: number /** Loop wrap size (single copy + seam gap) along each axis; 0 on the * non-scroll axis. Lets morph engines treat a chip and its loop clone as * ONE identity `wrap` apart (the Swiper loop-clone model) and fly the * copy the viewer actually sees. */ wrapX: number wrapY: number } /** * Registry of each named wall's CURRENT applied translation, written every * engine frame and deliberately kept after unmount: a FLIP-across-remounts * morph (deck `useFlipMorph`) captures element positions at COMMIT time, so * chips inside a drifting marquee would fly from stale origins. With a * `trackId` the wall stamps `data-marquee-track` on its container and * publishes its offset here; the morph engine corrects captured points by * the wall's drift since capture (including drift accrued right up to the * source wall's unmount — hence the survive-unmount map). */ const MARQUEE_TRACK_OFFSETS = new Map() const ZERO_OFFSET: MarqueeTrackOffset = { x: 0, y: 0, wrapX: 0, wrapY: 0 } /** Last known translation + wrap of the wall registered under `trackId` * (live or already unmounted). Zeros for unknown ids / static walls. */ export function getMarqueeTrackOffset(trackId: string): MarqueeTrackOffset { return MARQUEE_TRACK_OFFSETS.get(trackId) ?? ZERO_OFFSET } /** * The translation ACTUALLY applied to a wall's track right now, read from the * DOM of the `data-marquee-track` container. Morph engines use this on the * CAPTURE side (never the registry): a same-id wall freshly remounted in the * current commit has not applied any transform yet, while the registry still * holds its predecessor's final drift for the flight-side lookup. */ export function getMarqueeAppliedOffset(trackContainer: Element): { x: number; y: number } { const track = trackContainer.firstElementChild const t = track ? getComputedStyle(track).transform : 'none' if (!t || t === 'none') return { x: 0, y: 0 } const m = new DOMMatrixReadOnly(t) return { x: m.e, y: m.f } } /** * The loop-clone twin of an element inside a wall's PRIMARY copy (same index * in the `data-marquee-copy="clone"` copy), or null when there is no clone / * the element isn't in the primary copy. Owns the wall's internal DOM shape * so morph engines don't hardcode it; `selector` scopes which descendants * count as items (default: direct structure-preserving lookup by class is the * caller's job — pass the item selector, e.g. `'.flipchip'`). */ export function getMarqueeCloneTwin( trackContainer: Element, el: Element, selector: string, ): HTMLElement | null { const primary = trackContainer.querySelector('[data-marquee-copy="primary"]') const clone = trackContainer.querySelector('[data-marquee-copy="clone"]') if (!primary || !clone || !primary.contains(el)) return null const idx = Array.prototype.indexOf.call(primary.querySelectorAll(selector), el) if (idx < 0) return null return (clone.querySelectorAll(selector)[idx] as HTMLElement | undefined) ?? null } export interface MarqueeWallProps { /** * Presentation mode — the consumer's one switch for "do I want the * blur + marquee here at all": * - `'animated'` (default): endless marquee when content overflows, with * the overflow fades. * - `'plain'`: no motion, no fades — a plain clipped wall. All marquee * machinery (engine, clone copy, fades) is skipped entirely. */ mode?: 'animated' | 'plain' /** * Marquee axis. Defaults to where the fade sits — the animation's job is to * reveal what the fade hides: a `bottom`/`top` fade clips rows → vertical * (content travels bottom→top); a `left`/`right`-only fade clips columns → * horizontal (content travels right→left). No fade → horizontal. */ axis?: 'x' | 'y' /** Reverse travel: `x` reversed moves content right, `y` reversed moves it * down. Default false (left / up). */ reverse?: boolean /** Scroll speed in px/s (the CardsStrip unit — one speed language across * every marquee). Default 40: calm chip-wall drift, slower than the 60 the * card strips cruise at. */ speed?: number /** Master switch. Even when true the marquee auto-disables while the content * fits the container and under `prefers-reduced-motion` (static wall). */ animate?: boolean /** Freeze while a hover-capable pointer is anywhere over the wall — content * must never move under a pointing cursor (required for interactive chip * walls). Default true. */ pauseOnHover?: boolean /** Opt-in MANUAL scroll: pointer-drag + horizontal wheel move the wall along * its axis (transform-based — no inner scroller is created, so it stays * deck-safe). The auto-marquee pauses during the interaction and briefly * after, then resumes drifting from wherever the user left it. Off by * default (decorative walls stay auto-only); enabled on the embeddable-chat * quick-action walls so users can browse the actions by hand. */ dragScroll?: boolean /** Edge fade(s) — the "there's more" affordance. ONE spelling of the * clip-and-fade across every wall surface. */ fade?: MarqueeWallFadeEdge | ReadonlyArray /** The color the fades blend INTO — must match the surface behind the wall * or the gradient paints a visible box. Default the page background. */ fadeColor?: string /** Fade thickness: one value for all edges or a per-edge map. Defaults: * 40px vertical edges, 112px horizontal edges (the shipped hero-wall * geometry). */ fadeSize?: number | string | Partial> /** Gap between the two track copies — match the content's own item gap so * the seam is invisible. A CSS length string (e.g. `var(--tlcl-chipgap)`) * lets responsive CSS own the value; the true seam distance is MEASURED * from the rendered copies either way. Default 8. */ copyGap?: number | string /** Outer container class — sizing lives here (height cap for `y`, width for * `x`). The container is always `relative overflow-hidden`. */ className?: string /** Class on EACH track copy — the wall's own layout (e.g. a wrapping chip * row `flex flex-wrap gap-2`, or a fixed-width grid). */ contentClassName?: string /** Inline style on EACH track copy — for values classes can't carry (e.g. * an item gap driven by the same variable as `copyGap`, so chip pitch and * seam pitch stay one value by construction). */ contentStyle?: React.CSSProperties /** * Wall content — ONE copy; the wall clones it for the endless loop (clone * `aria-hidden` + focus-suppressed but still clickable). Pass a render * callback to vary the clone copy (e.g. omit `data-flip-id`s / render * decorative chips). Children must be clone-safe: ref-free, unique-DOM-id- * free, mount-effect-free (same contract as children mode). */ children: React.ReactNode | ((ctx: { isClone: boolean }) => React.ReactNode) /** Pair this wall with sibling walls under one shared position driver. */ sync?: MarqueeSyncController /** Stable id for FLIP coordination: publishes the wall's live offset via * {@link getMarqueeTrackOffset} and stamps `data-marquee-track` on the * container so morph engines can drift-correct captured positions. */ trackId?: string } // ============================================================================= // Internals // ============================================================================= const DEFAULT_FADE_SIZE: Record = { top: '40px', bottom: '40px', left: '112px', right: '112px', } const FADE_POSITION: Record = { top: 'inset-x-0 top-0', bottom: 'inset-x-0 bottom-0', left: 'inset-y-0 left-0', right: 'inset-y-0 right-0', } /** The wall's edge-fade overlays — exported so composite walls (stacked row * marquees) can draw ONE set of fades over the whole stack instead of * per-row copies. Parent must be `relative`. Overlays sit at z-1, ABOVE * FLIP flight ghosts (z-0 by contract): an in-flight chip dissolves under * the blur exactly like a resting chip. */ export function MarqueeWallFades({ fade, fadeColor = 'var(--color-bg)', fadeSize, }: { fade?: MarqueeWallFadeEdge | ReadonlyArray fadeColor?: string fadeSize?: MarqueeWallProps['fadeSize'] }) { return ( <> {normalizeFades(fade).map(edge => { const size = (typeof fadeSize === 'object' && fadeSize !== null ? fadeSize[edge] : fadeSize) ?? DEFAULT_FADE_SIZE[edge] const px = typeof size === 'number' ? `${size}px` : size const isY = edge === 'top' || edge === 'bottom' return (
) })} ) } /** Clone track copy — aria-hidden with focusable descendants suppressed (but * clickable, the shared loop-clone treatment). */ function CloneCopy({ className, style, children, }: { className?: string style?: React.CSSProperties children: React.ReactNode }) { const ref = useSuppressCloneFocus(true) return (
{children}
) } // ============================================================================= // Component // ============================================================================= /** * — THE endless-scroll wall container: a clipped area whose * content loops as a marquee along either axis, fading out where the clip * cuts. Built on the same `useMarqueeEngine` core as (the news / * logo / bites strips) with a TRANSFORM driver instead of a scroller — no * inner scrollable area is created, so it is safe inside surfaces that forbid * nested scrollers (the deck's mobile single-fold panels). * * Static by circumstance, not by variant: when the content fits the container * (or `prefers-reduced-motion`) the wall renders a single static copy with * its fades — byte-identical to the old non-animated walls. */ export function MarqueeWall({ mode = 'animated', axis: axisProp, reverse = false, speed = 40, animate = true, pauseOnHover = true, dragScroll = false, fade, fadeColor = 'var(--color-bg)', fadeSize, copyGap = 8, className, contentClassName, contentStyle, children, sync, trackId, }: MarqueeWallProps) { const fades = React.useMemo(() => normalizeFades(fade), [fade]) const axis = axisProp ?? (fades.includes('top') || fades.includes('bottom') ? 'y' : 'x') const containerRef = React.useRef(null) const trackRef = React.useRef(null) const copyRef = React.useRef(null) // ---- environment ----------------------------------------------------------- const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)') ?? false // ---- overflow + seam measurement ------------------------------------------- // One copy vs the container along the axis decides overflow (no overflow → // nothing hidden → no marquee, no clone). The WRAP size — the loop's seam // distance — is MEASURED as the offset delta between the two rendered // copies, so it is exact whatever produced the gap (numeric prop or a // responsive CSS variable); before the clone mounts it falls back to // copy size + numeric gap, corrected on the next measure. const [overflows, setOverflows] = React.useState(false) const copySizeRef = React.useRef(0) const wrapSizeRef = React.useRef(0) const measure = React.useCallback(() => { const container = containerRef.current const copy = copyRef.current if (!container || !copy) return const size = axis === 'y' ? copy.offsetHeight : copy.offsetWidth copySizeRef.current = size const clone = trackRef.current?.querySelector('[data-marquee-copy="clone"]') wrapSizeRef.current = clone ? axis === 'y' ? clone.offsetTop - copy.offsetTop : clone.offsetLeft - copy.offsetLeft : size + (typeof copyGap === 'number' ? copyGap : 0) const avail = axis === 'y' ? container.clientHeight : container.clientWidth setOverflows(size > avail + 1) }, [axis, copyGap]) // Structural gate — the wall IS a marquee (clone copy + transform driver // mounted) whenever its content overflows and motion is allowed, INDEPENDENT // of the per-beat `animate` run-gate. Keeping it mounted across a pause lets // the wall FREEZE in place (rAF stops, transform + clone stay put) instead of // snapping the track back to its origin — that reset read as a flicker every // time a stepped slide advanced past a wall's active beat, or the deck-wide // WCAG pause toggled. Only genuinely going static (content now fits, or // reduced-motion) unmounts the clone and clears the transform. const marqueeMounted = mode === 'animated' && !reducedMotion && overflows // Run-gate — the rAF drives the track only while this wall's beat is active. // Toggling it (step-gate / deck-wide pause / off-screen) freezes and resumes // in place; the velocity envelope persists so resume is at cruise, not a // 0→speed ramp (same contract as the `nearViewport` gate below). const marqueeActive = marqueeMounted && animate React.useEffect(() => { measure() const container = containerRef.current const copy = copyRef.current if (!container || !copy || typeof ResizeObserver === 'undefined') return const ro = new ResizeObserver(measure) ro.observe(container) ro.observe(copy) return () => ro.disconnect() // marqueeMounted: the clone mounts with it — re-measure for the true seam. }, [measure, marqueeMounted]) // Viewport gates `active` (rAF fully stops off-screen) instead of being a // pause REASON: the velocity envelope persists across engine restarts, so // scrolling a wall back into view resumes at cruise speed instantly — a // pause-reason resume would ramp 0→speed over ~250ms, a visible // stopped-then-start on every slide/section entry. const [nearViewport, setNearViewport] = React.useState(true) React.useEffect(() => { const el = containerRef.current if (!el || typeof IntersectionObserver === 'undefined') return const io = new IntersectionObserver( entries => setNearViewport(entries[0]?.isIntersecting ?? true), { rootMargin: NEAR_VIEWPORT_ROOT_MARGIN }, ) io.observe(el) return () => io.disconnect() }, []) // ---- pause-reason set ------------------------------------------------------ const pointerInsideRef = React.useRef(false) const clearPointerInside = React.useCallback(() => { pointerInsideRef.current = false }, []) const onPointerEnter = React.useCallback((e: React.PointerEvent) => { if (e.pointerType === 'touch') return pointerInsideRef.current = true }, []) const onPointerLeave = React.useCallback((e: React.PointerEvent) => { if (e.pointerType === 'touch') return pointerInsideRef.current = false }, []) // Stale-flag self-heal (shared SSOT): if a `pointerleave` is MISSED (tab blur, // overlay under the cursor, interrupted pointer), `inside` stays stuck true and // the wall freezes forever — so un-stick it whenever the tab hides / the window // loses focus. The next real pointer/enter re-arms it. useResetOnPageHidden(clearPointerInside) // ---- manual drag / wheel scroll (opt-in via `dragScroll`) ------------------ // Transform-based (no inner scroller → deck-safe): drag/wheel move the // engine's float position directly; `draggingRef` + a short suppress window // pause the auto-marquee during and just after the interaction so it resumes // drifting from where the user left it. Refs declared here (before the engine) // so `isPaused` can read them; the handlers (which need the engine's `wrap`) // are defined after the engine call. const draggingRef = React.useRef(false) const dragSuppressUntilRef = React.useRef(0) // ---- transform driver ------------------------------------------------------ const applyTransform = React.useCallback( (pos: number) => { const track = trackRef.current if (!track) return const wrapSize = wrapSizeRef.current const t = reverse ? pos - wrapSize : -pos track.style.transform = axis === 'y' ? `translate3d(0, ${t}px, 0)` : `translate3d(${t}px, 0, 0)` if (trackId) { MARQUEE_TRACK_OFFSETS.set( trackId, axis === 'y' ? { x: 0, y: t, wrapX: 0, wrapY: wrapSize } : { x: t, y: 0, wrapX: wrapSize, wrapY: 0 }, ) } }, [axis, reverse, trackId], ) // Sync-aware position writer — THE single entry point for every position // update (the engine's rAF apply AND the manual drag/wheel handlers). With a // `sync` controller it publishes to the shared position and drives every peer // wall's transform; without one it just writes our own. Routing drag/wheel // through here (instead of raw `applyTransform`) keeps a wall that is both // `sync`-ed and `dragScroll`-ed in lockstep with its peers — a direct // `applyTransform` would move only this wall and leave `sync.position` + peers // stale. Members' own `apply` stays the raw `applyTransform` (no re-publish), // so the `forEach` fan-out can't recurse. const applyPos = React.useCallback( (pos: number) => { if (sync) { sync.position = pos sync.members.forEach(m => m.apply(pos)) } else { applyTransform(pos) } }, [sync, applyTransform], ) // Clear any residual offset only when the marquee turns off STRUCTURALLY // (content shrank so it fits, reduced-motion flipped) — the static wall must // sit at its natural origin. A mere run-gate pause (`animate` false) does NOT // clear it: the frozen track stays exactly where it stopped (no flicker). The // engine position resets with it (below the engine call) so a later // re-activation re-seeds consistently. React.useEffect(() => { if (marqueeMounted) return const track = trackRef.current if (track) track.style.transform = '' if (trackId) MARQUEE_TRACK_OFFSETS.set(trackId, ZERO_OFFSET) }, [marqueeMounted, trackId]) // NOTE deliberately no reset of the registry entry on mount: when a layout // change remounts the SAME trackId in one commit, the morph engine must // still read the OLD wall's final drift for that commit's flight origins. // The new wall's first applied frame (rAF, after all layout effects) // overwrites it. Capture-side reads take the translation from the DOM // ({@link getMarqueeAppliedOffset}), so a stale registry value can never // leak into captured points. // ---- sync pairing (driver election by mount order, with re-election) ------- const [isDriver, setIsDriver] = React.useState(!sync) React.useEffect(() => { if (!sync) { setIsDriver(true) return } const member: MarqueeSyncMember = { apply: applyTransform, makeDriver: () => setIsDriver(true) } sync.members.add(member) if (sync.driver === null) sync.driver = member setIsDriver(sync.driver === member) return () => { sync.members.delete(member) if (sync.driver === member) { // Promote the next surviving member so the pair never goes driverless // (a driverless pair would freeze — nothing runs the shared rAF). const next = sync.members.values().next() sync.driver = next.done ? null : next.value sync.driver?.makeDriver() } } }, [sync, applyTransform]) const { posRef, wrap } = useMarqueeEngine({ active: marqueeActive && nearViewport && isDriver, speed, isPaused: now => (pauseOnHover && pointerInsideRef.current) || draggingRef.current || now < dragSuppressUntilRef.current || document.visibilityState === 'hidden', getWrapSize: () => wrapSizeRef.current, apply: applyPos, // Synced pairs: a re-elected driver resumes from the pair's live position // (engine seeds from readBack on activation) instead of restarting at 0. readBack: sync ? () => sync.position : undefined, }) // REVERSE-travel continuity: an unstyled track (t = 0) corresponds to // pos = wrapSize, not pos = 0 — seed the position on activation so the // engine's first frame continues from the freshly-painted state instead of // teleporting the track a full wrap upward. Without this, a FLIP morph // capturing against the mount paint saw every chip (and every in-flight // ghost target) jump a copy-height on the next frame — the "chips pour in // from the top instead of flying between panels" breakage. React.useLayoutEffect(() => { if (!marqueeMounted) { posRef.current = 0 return } if (reverse && posRef.current === 0) { posRef.current = Math.max(0, wrapSizeRef.current - 0.01) } }, [marqueeMounted, reverse, posRef]) // ---- manual drag / wheel handlers (only wired when `dragScroll`) ----------- const DRAG_RESUME_MS = 1200 const dragStartRef = React.useRef({ coord: 0, pos: 0 }) const dragMovedRef = React.useRef(false) const suppressClickRef = React.useRef(false) const onDragPointerDown = React.useCallback((e: React.PointerEvent) => { if (!marqueeMounted) return draggingRef.current = true dragMovedRef.current = false // Clear any click-suppress left armed by a prior drag that ended WITHOUT a // synthetic click on this container (pointercancel, or the pointer released // off the wall) — otherwise `onDragClickCapture` would swallow this fresh // tap's action. A new press is unambiguously a new intent. suppressClickRef.current = false dragStartRef.current = { coord: axis === 'y' ? e.clientY : e.clientX, pos: posRef.current } // NOTE deliberately NO setPointerCapture here — capture is taken LAZILY in // `onDragPointerMove`, once the press is proven to be a drag. Capturing on // pointerdown retargets the compatibility mouse events (mouseup, and hence // `click`, which fires at the nearest common ancestor of down/up) from the // pressed element to this container — so a plain tap on a quick-action chip // never reaches the chip's own onClick and the whole wall reads as dead. }, [marqueeMounted, axis, posRef]) const onDragPointerMove = React.useCallback((e: React.PointerEvent) => { if (!draggingRef.current) return const delta = (axis === 'y' ? e.clientY : e.clientX) - dragStartRef.current.coord if (Math.abs(delta) > 3 && !dragMovedRef.current) { dragMovedRef.current = true // Past the threshold this IS a drag: capture so the gesture keeps // tracking once the pointer leaves the wall. Retargeting click is now // WANTED — the trailing synthetic click is suppressed below anyway. try { containerRef.current?.setPointerCapture(e.pointerId) } catch { /* best-effort */ } } // Track follows the pointer. `applyPos` maps pos→translate as `-pos` // (forward) / `pos-wrap` (reverse), so a rightward/downward drag (delta > 0) // must DECREASE pos on a forward wall and INCREASE it on a reverse one. // Via `applyPos` (not raw `applyTransform`) so synced peer walls follow. posRef.current = wrap(dragStartRef.current.pos + (reverse ? delta : -delta)) applyPos(posRef.current) dragSuppressUntilRef.current = performance.now() + DRAG_RESUME_MS }, [axis, reverse, wrap, posRef, applyPos]) const onDragPointerUp = React.useCallback((e: React.PointerEvent) => { if (!draggingRef.current) return draggingRef.current = false // A real drag ends over a chip → swallow the synthetic click so the drag // doesn't fire the chip's action. if (dragMovedRef.current) suppressClickRef.current = true dragSuppressUntilRef.current = performance.now() + DRAG_RESUME_MS // Only ever captured past the drag threshold (see `onDragPointerMove`). if (dragMovedRef.current) { try { containerRef.current?.releasePointerCapture(e.pointerId) } catch { /* best-effort */ } } }, []) // Pre-threshold presses hold NO pointer capture, so a pointer that leaves the // wall before becoming a drag would never deliver its `pointerup` here and // `draggingRef` would stay stuck true — freezing the marquee forever. End the // gesture on leave; a captured (real) drag keeps running, capture guarantees // its own pointerup. const onDragPointerLeave = React.useCallback((e: React.PointerEvent) => { onPointerLeave(e) if (draggingRef.current && !dragMovedRef.current) draggingRef.current = false }, [onPointerLeave]) const onDragClickCapture = React.useCallback((e: React.MouseEvent) => { if (suppressClickRef.current) { suppressClickRef.current = false e.preventDefault() e.stopPropagation() } }, []) // Along-axis wheel → move the wall. Wired as a NATIVE non-passive listener // (not React's `onWheel`, which React attaches passively so `preventDefault` // is a no-op): once WE consume an along-axis wheel we must `preventDefault` to // suppress the browser's horizontal history-swipe (two-finger trackpad) on an // x-wall. An x-wall ignores vertical page-scroll wheels (and vice-versa) so the // surrounding page still scrolls normally. React.useEffect(() => { const el = containerRef.current if (!el || !dragScroll || !marqueeMounted) return const onWheel = (e: WheelEvent) => { // Only claim the DOMINANT-axis wheel (per axis), so a cross-axis wheel // still scrolls the page: an x-wall ignores vertical wheels, a y-wall // ignores horizontal ones. const primary = axis === 'y' ? Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : 0 : Math.abs(e.deltaX) >= Math.abs(e.deltaY) ? e.deltaX : 0 if (primary === 0) return e.preventDefault() // Via `applyPos` (not raw `applyTransform`) so synced peer walls follow. posRef.current = wrap(posRef.current + (reverse ? -primary : primary)) applyPos(posRef.current) dragSuppressUntilRef.current = performance.now() + DRAG_RESUME_MS } el.addEventListener('wheel', onWheel, { passive: false }) return () => el.removeEventListener('wheel', onWheel) }, [dragScroll, marqueeMounted, axis, reverse, wrap, posRef, applyPos]) // ---- render ---------------------------------------------------------------- const renderContent = (isClone: boolean) => typeof children === 'function' ? children({ isClone }) : children // Reduced-motion static fallback: when the marquee does NOT mount (chiefly // prefers-reduced-motion) but the content OVERFLOWS, the drag/transform path // is inert — so on a `dragScroll` wall fall back to NATIVE // overflow scroll along the axis. Otherwise the overflowing chips would be // clipped by `overflow-hidden` with no way to reach them (a11y regression vs // the old wrapping row). The scrollbar itself stays hidden (`scrollbar-hide`) // so the static wall matches the marquee's chrome-free look — native wheel // and touch scrolling still work (the transform-based drag path is gated on // `marqueeMounted`, so pointer drag is marquee-only). No engine needed — the // browser scrolls the single static track. const staticScrollable = dragScroll && overflows && !marqueeMounted return (
{renderContent(false)}
{marqueeMounted && ( {renderContent(true)} )}
{/* A fade means "there's more": it only paints while the content actually overflows the container. A wall taller than its content (e.g. a flex-filled panel) must show every chip crisp — a veil over real rows and empty surface reads as a rendering bug. Plain mode skips fades entirely (the consumer opted out of blur + marquee). */} {mode === 'animated' && overflows && ( )}
) }