Scroll arrivals
StableContent that fades and rises in as it scrolls into view, bars that grow, stamps that land and a frame that draws itself.
useLuxe() (or useReveal) arms a root. Inside it, .lux-reveal, .lux-grow, .lux-stamp and .lux-frame start hidden and arrive once, the first time they scroll into view. Content is visible at rest: only an armed root with motion allowed hides it, so prerendered HTML, pages before JavaScript and reduced-motion visitors show everything at once.
Reveal and stagger
import { useState } from 'react'import { useLuxe } from '@rchursin/components/motion'import { delay } from '@rchursin/home/ui'import { Button } from '@rchursin/product/ui'
/** * useLuxe() arms the root; each .lux-reveal fades and rises in as it scrolls into view, 70ms * after the one before (delay(n)). Replay remounts the content so you can watch it again. * With reduced motion everything is simply there. */const changes = [ { title: 'Parallel scans', body: 'Large repositories finish in seconds.' }, { title: 'Overlap finder', body: 'Duplicate skills, side by side.' }, { title: 'Quieter reports', body: 'What to change comes first.' },]
export default function ArrivalsReveal() { const root = useLuxe<HTMLDivElement>() const [run, setRun] = useState(0) return (Usage
import { useLuxe, useReveal } from '@rchursin/components/motion'import { delay } from '@rchursin/home/ui'const root = useLuxe<HTMLDivElement>()
return ( <div ref={root}> <h2 className="lux-reveal lux-reveal-blur">Release 2.4</h2> <p className="lux-reveal" style={delay(1)}>Faster scans, fewer surprises.</p> </div>)Examples
Bars and a stamp
useReveal on its own: bars grow from the left, then a verdict lands like a rubber stamp.
import { useRef, useState } from 'react'import { useReveal } from '@rchursin/components/motion'import { delay } from '@rchursin/home/ui'import { Button } from '@rchursin/product/ui'import { SampleBadge } from '@rchursin/product/shell/layout'
/** * useReveal on its own (no pointer light): .lux-grow bars grow from the left edge, 90ms apart, * and a .lux-stamp lands like a rubber stamp once they are in. */const spend = [ { agent: 'test-fixer', usd: 142 }, { agent: 'pr-review', usd: 96 }, { agent: 'release-notes', usd: 41 }, { agent: 'db-migrate', usd: 12 },]const max = Math.max(...spend.map((s) => s.usd))
export default function ArrivalsGrowStamp() { const root = useRef<HTMLDivElement>(null)Frame and surfacing words
The 09 Frame draws itself from the corner. The words use .lux-surface, a one-time fade on mount that needs no scrolling.
import { useState, type CSSProperties } from 'react'import { useLuxe } from '@rchursin/components/motion'import { Button } from '@rchursin/product/ui'
/** * .lux-frame draws the logo's geometry: the L grows from its corner (.lux-draw-v, .lux-draw-h) * and the square settles in (.lux-draw-sq). .lux-surface is different: a one-time fade on mount, * no scrolling needed, ordered by --w in milliseconds. */const words = ['scan', 'measure', 'compare', 'keep', 'fix', 'merge', 'cut']
export default function ArrivalsFrameSurface() { const root = useLuxe<HTMLDivElement>() const [run, setRun] = useState(0) return ( <div ref={root} className="flex w-full max-w-md flex-col items-start gap-5"> <div key={run} className="relative w-full px-8 py-14 text-fg"> <div aria-hidden="true" className="lux-frame pointer-events-none absolute inset-0" style={{ '--fs': '8px' } as CSSProperties}> <span className="lux-draw-v absolute bottom-0 left-0 h-[30%] w-(--fs) bg-current" /> <span className="lux-draw-h absolute bottom-0 left-0 h-(--fs) w-[30%] bg-current" /> <span className="lux-draw-sq absolute top-0 right-0 size-[calc(var(--fs)*2.57)] bg-current" /> </div> <p className="flex flex-wrap gap-x-4 gap-y-1 font-mono text-[17px]"> {words.map((w, i) => ( <span key={w} className="lux-surface" style={{ '--w': i * 180 } as CSSProperties}> {w} </span> ))} </p> </div> <Button variant="secondary" size="sm" icon="rotate-ccw" onClick={() => setRun((n) => n + 1)}> Replay </Button> </div> )}API
useLuxe<T>()
| Prop | Type | Default | Description |
|---|---|---|---|
T | T extends HTMLElement | – | The root element’s type, e.g. useLuxe<HTMLDivElement>(). |
returns | RefObject<T | null> | – | Put it on the page root. It runs useReveal and usePointerSpotlight on that element: one call for arrivals and the pointer light. |
useReveal(root)
| Prop | Type | Default | Description |
|---|---|---|---|
rootrequired | RefObject<HTMLElement | null> | – | Sets data-reveal="armed" on it before the first paint and watches every .lux-reveal, .lux-frame, .lux-stamp and .lux-grow inside, including ones added later. Each gets .is-in once, when 12% of it is in view (8% above the bottom of the window). When the root hydrates prerendered HTML, what is already on screen gets .is-in straight away and stays put; only what is below the fold arrives. |
delay(d)
| Prop | Type | Default | Description |
|---|---|---|---|
drequired | number | – | The stagger step, returned as a style object ({ "--d": d }). 70ms a step for .lux-reveal and .lux-stamp, 90ms a step (after 150ms) for .lux-grow. |
CSS
| Class or variable | What it does |
|---|---|
lux-reveal | Starts 16px low and transparent; arrives over 900ms (fade) and 1100ms (rise) on ease-lux-expo, after --d × 70ms. |
lux-reveal-blur | Add to a .lux-reveal to start it 8px blurred as well, so it comes into focus. Best on one heading per section. |
lux-grow | Starts at zero width, scaled from the left edge, and grows over 1300ms after --d × 90ms + 150ms. For bars and rules. |
lux-stamp | Starts at 150% scale and transparent, then lands in 650ms on ease-lux-spring after --d × 70ms. |
lux-frame | The 09 Frame drawing itself. Inside it, .lux-draw-v grows up from the bottom, .lux-draw-h grows right 120ms later and .lux-draw-sq settles in at 380ms. FrameCorners renders this markup for you. |
lux-surface | A one-time 1400ms fade on mount, delayed by --w milliseconds. It needs no root and no scrolling. |
--d, --w | Stagger: --d is a step count (use delay(n)); --w is a delay in milliseconds for .lux-surface. |
Accessibility
- Arrivals only change opacity, position, scale and blur. The content is in the page and readable by screen readers from the start.
- The CSS that hides content sits inside prefers-reduced-motion: no-preference, so with reduced motion nothing is hidden and nothing moves.
- Each element arrives once and stays; nothing hides again when it scrolls away.
- On a prerendered page, content the visitor can already see is never hidden again when the script starts: it stays as it is, and only what is below the fold arrives.
- Browsers without IntersectionObserver never arm the root, so content simply shows.
Guidelines
Do
- Call useLuxe once on the page root; everything inside opts in with a class.
- Give a lazily loaded part (a Suspense boundary that hydrates later) its own useLuxe root instead of relying on the page’s.
- Stagger siblings with delay(0) to delay(6) so the whole sequence lands within about a second.
- Use lux-reveal-blur on headings only.
Don’t
- Don’t stagger long lists item by item; reveal the list as one block.
- Don’t put arrivals on errors, alerts or the first field of a form.
- Don’t add transition-* utilities to an arriving element: they replace its arrival.
In use
Used on this site
Related
- Easing & transitionsFour motion curves, a soft cross-fade for view changes, a reduced-motion hook and disclosures that ease open.
- Count upA number that counts up once, the first time it scrolls into view. Screen readers get the final value straight away.
- Frame cornersThe logo’s two shapes as a viewfinder: an L bottom-left and a square top-right, drawn around any box.
- Pointer lightA soft pool of light that follows the mouse over cards, and a lens that shows a brighter copy of a background.
Source
The component’s own files, exactly as they run on this site.
/** * Small motion helpers for the luxe layer (see styles/luxe.css). No animation library. * Every helper leaves content visible and still when motion is reduced or JS hasn't run. */import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type RefObject,} from "react"import { flushSync } from "react-dom"
const REVEAL_SELECTOR = ".lux-reveal, .lux-frame, .lux-stamp, .lux-grow"
/** true when the visitor allows motion (re-evaluated on change) */export function useMotionOK() { const [ok, setOk] = useState(false) useEffect(() => {