Loading page…
Six primitives that read the scroll position and spend it: content reveals on entry, an indicator tracks depth, layers drift at different rates, a strip runs forever, and a Lenis provider eases both the wheel and the jump. Every demo runs inside its own fixed-height scroll port, so a specimen never borrows the page's scroll from its neighbour.
ScrollReveal wraps content in an in-view trigger — it fades up from y with an entry blur when it crosses the threshold. Pass root when the scroll happens inside a container rather than the viewport; without it the observer watches the page. Under reduced motion the slide and blur are dropped and only the opacity survives. The reveal settles on filter:none, never blur(0px) — motion keeps the resolved filter as an inline style, and any filter but none turns the element into a containing block for position:fixed descendants.
y=16, blur=8, duration=0.6, amount=0.4. Shown with once=false so the cards re-arm on the way back up.
y=48, blur=0, duration=0.9, amount={0.6} — a heavier arrival that waits until most of the card is in the port.
import { ScrollReveal } from "@coldharbor/webui";
const port = useRef<HTMLDivElement>(null);
<div ref={port} className="h-56 overflow-y-auto">
{rows.map((row) => (
<ScrollReveal key={row.id} root={port} y={16} blur={8} amount={0.4}>
<LedgerRow {...row} />
</ScrollReveal>
))}
</div>--ch-bg--ch-bg-raised--ch-surface--ch-border--ch-border-faint--ch-fg--ch-fg-muted--ch-fg-faint--ch-motion-ease-outScrollProgress is one component with two shapes. The bar scales on X from the left; the circle draws a stroke-dashoffset ring. Both default to the ambient page scroll via useSmoothScroll — pass progress to bind them to a container instead. spring softens the follow and is disabled automatically under reduced motion.
fixed={false} pins the bar absolute inside the port's wrapper instead of to the window. Scroll the port to draw it.
size=40, thickness=4. The faint backing ring is always drawn, so the control has a resting silhouette at 0.
progress = 0.42
Any MotionValue works as the source — here a constant, so the geometry reads without scrolling.
import { ScrollProgress } from "@coldharbor/webui";
// Page-wide: reads useSmoothScroll() with no wiring.
<ScrollProgress position="top" height={2} />
// Bound to a container.
const { scrollYProgress } = useScroll({ container: port });
<ScrollProgress progress={scrollYProgress} fixed={false} height={3} />
<ScrollProgress variant="circle" progress={scrollYProgress} size={40} thickness={4} />--ch-bg--ch-bg-raised--ch-surface--ch-border--ch-border-faint--ch-fg--ch-fg-muted--ch-fg-faint--ch-focusParallax maps the element's travel across the scroll port onto a symmetric drift of speed × 100px. Positive moves with the scroll and reads as foreground; negative moves against it and reads as depth. 0.1–0.5 is the usable band — past that the layer outruns its frame. Under reduced motion the transform is dropped entirely and the layer sits still.
Foreground drifts with the scroll
Background runs against it. Negative speed reads as depth.
Coordinates at speed=-0.25 behind a card at speed=0.18, both bound to the port via container.
axis="x" · speed 0.3 · spring off
axis='x' with spring={false} — the raw scroll-linked value, no follow lag.
import { Parallax } from "@coldharbor/webui";
const port = useRef<HTMLDivElement>(null);
<Parallax container={port} speed={-0.25}>{background}</Parallax>
<Parallax container={port} speed={0.18}>{card}</Parallax>--ch-bg--ch-bg-raised--ch-surface--ch-border--ch-border-faint--ch-fg--ch-fg-muted--ch-fg-faintThe one primitive here that never reads the scroll position — it runs on its own clock. Marquee duplicates the track and translates it by -100% minus the gap, so the seam lands exactly one gap after the last item; the second copy is aria-hidden and inert so screen readers and tab order see the strip once. speed is the loop duration in seconds, not a rate — larger is slower.
direction='left', speed=30, fade and pauseOnHover on. Hover to stop it.
direction='right', speed={16} — half the loop duration, twice the pace.
fade={false} drops the mask; use it when the strip already sits inside a hard-clipped frame.
direction='up' needs a bounded height on the wrapper; the track has no intrinsic stop.
direction='down' runs the same track in reverse; the fade mask flips to the vertical axis with it.
import { Marquee } from "@coldharbor/webui";
<Marquee direction="left" speed={30} gap="1rem" fade pauseOnHover>
{ticks.map((tick) => <Tick key={tick}>{tick}</Tick>)}
</Marquee>--ch-bg--ch-bg-raised--ch-surface--ch-border--ch-border-faint--ch-fg--ch-fg-muted--ch-fg-faint--ch-radius-pillSmoothScroll is a provider, not a decoration: it wraps a scroll container, hands Lenis the wheel, and publishes scrollY, progress and velocity as MotionValues on context. ScrollTo is a plain button that calls scrollTo from that context. The cost is real — a Lenis instance, a RAF loop, and a scroll position the browser no longer owns, which breaks scroll anchoring and native momentum expectations. Reach for it on a designed marketing surface; leave the wallet's own scroll native.
Berth A — cold storage
Lenis eases the wheel and the programmatic jump on the same curve, so a click and a flick arrive the same way.
01 / 03
Berth B — operations
Lenis eases the wheel and the programmatic jump on the same curve, so a click and a flick arrive the same way.
02 / 03
Berth C — reserve
Lenis eases the wheel and the programmatic jump on the same curve, so a click and a flick arrive the same way.
03 / 03
Click a berth to ease there. Under reduced motion the provider skips Lenis entirely: it renders a plain div, syncs the native scroll into the same MotionValues, and ScrollTo jumps instantly.
import { ScrollTo, SmoothScroll, useSmoothScroll } from "@coldharbor/webui";
<SmoothScroll root={false} lerp={0.1} duration={1.2} className="h-72 overflow-y-auto">
<ScrollTo to="#berth-a" offset={-44}>Berth A</ScrollTo>
<ScrollTo to={0}>Top</ScrollTo>
</SmoothScroll>
// Anywhere inside: the shared scroll state.
const { progress, velocity, scrollTo } = useSmoothScroll();--ch-bg--ch-bg-raised--ch-surface--ch-border--ch-border-faint--ch-fg--ch-fg-muted--ch-fg-faint--ch-motion-duration-fast--ch-motion-duration-base--ch-motion-duration-slow--ch-motion-ease-outTwo paths, and they are not the same switch. Marquee is pure CSS, so packages/ui/src/styles.css pauses the animate-marquee keyframes under both the prefers-reduced-motion media query and the forced data-reduced-motion='reduce' attribute the styleguide toggle writes. The JS primitives — ScrollReveal, ScrollProgress, Parallax, SmoothScroll — call useReducedMotion, which reads the media query only: they degrade for a system preference, not for the in-page toggle.
Track runs at speed 30.
Same markup, animation-play-state paused. The strip is frozen mid-loop — nothing is hidden, the content just stops moving.
/* CSS side — styles.css, free for Marquee. */
@media (prefers-reduced-motion: reduce) {
.animate-marquee, .animate-marquee-vertical { animation-play-state: paused; }
}
[data-reduced-motion="reduce"] .animate-marquee { animation-play-state: paused; }
/* JS side — each primitive collapses its own transform. */
const reduce = useReducedMotion();
const hidden = reduce ? { opacity: 0 } : { opacity: 0, y, filter: `blur(${blur}px)` };--ch-motion-duration-fast--ch-motion-duration-base--ch-motion-duration-slow--ch-motion-ease-outTicker strip and a held progress ring on the canvas layer.
progress = 0.68
--ch-bg--ch-bg-raised--ch-surface--ch-border--ch-border-faint--ch-fg--ch-fg-muted--ch-fg-faint