Motion UIComponentsComponent

Scroll spotlight

Copy lines that brighten as they cross a reading band, driving a sticky product visual.

npx shadcn@latest add @motion/scroll-spotlight

API reference

Components

<ScrollSpotlight />

The provider that owns the single active-index state the whole mechanic shares. Renders no DOM of its own - the consumer owns all layout - it only supplies the context every other part and useSpotlight reads.

focusOffset?

number = 45

Half-height of the reading band as a percentage of the viewport, insetting the top and bottom so the band is a thin slice across the middle. Higher = a tighter band (a line must sit nearer dead-centre to become active). Default 45.

dimmedOpacity?

number = 0.4

The resting opacity of a non-active copy line under full motion (the active line always sits at full opacity). Default 0.4.

revealScale?

number = 1.03

The scale a sticky/inline visual reveals FROM (it settles to 1), the depth of the crossfade reveal. Dropped under calm (opacity only). Default 1.03.

initialItem?

number = 0

The item index active before any line has crossed the band. Default 0.

initialLine?

string = ""

The SpotlightCopy lineId treated as active before any scroll, so the opening line reads at full opacity on mount. Pass your first line's id (e.g. "0-t"). Default "" (no line pre-lit).

children?

ReactNode

The walkthrough: your copy column, rail, stage and visuals.

<SpotlightCopy />

A single line of walkthrough copy that dims to the resting opacity until it crosses the reading band, then brightens - and, on crossing, reports itself as the active line/index so the rail and sticky visual advance with it. Compositor-only (opacity), timed by the theme's snap spring. Under reduced motion it stays fully legible with no scroll tracking.

tag

"h2" | "h3" | "h4" | "p"

The element to render. Headings (h2/h3/h4) carry the walkthrough structure; p is a body line.

lineId

string

A stable id for THIS line, unique across the walkthrough (e.g. ${item}-t for a title, ${item}-b${n} for body lines). It is what marks the line active, not the DOM id.

itemIndex

number

The item (feature) index this line belongs to. When the line crosses the band it becomes the active index, advancing the rail and the sticky visual.

id?

string

The element's DOM id, for aria-labelledby wiring (a heading labelling its article). The dimming is purely visual and never hidden from a screen reader.

className?

string

Merged onto the element - style the copy here.

children?

ReactNode

The line's text.

<SpotlightRail />

The discrete step-dot column for the walkthrough. Discrete on purpose: the steps are distinct features, not a continuous quantity, so a filled gauge would imply data that is not there. The active dot scales up on the theme's snap spring and carries a static primary halo; reached dots fill primary, unreached use a faint tone, and the colour shift is a CSS transition on the snap token. Decorative (the copy carries the meaning) - the consumer's column that wraps it is aria-hidden.

count

number

How many step dots to render (one per walkthrough item).

className?

string

Merged onto the <ol> - adjust spacing/alignment here.

<SpotlightStage />

The fixed 4:3 stage every product visual sits in, so a sticky crossfade and an inline reveal share exact dimensions. Sticky visuals stack absolutely inside it; an inline visual fills it.

children?

ReactNode

The stacked SpotlightVisuals (sticky column) or a single one (inline).

className?

string

Merged onto the stage - override the aspect ratio or sizing here.

<SpotlightVisual />

Wraps ONE of the consumer's product visuals with the sticky crossfade + scale reveal. In the sticky column it crossfades in as its index becomes active; inline it reveals once on scroll into view; under reduced motion it renders its final state (opacity fade only under calm, nothing under off). Hands the visual a revealed flag via render-function children so a bar fill or sparkline draw lands with the panel. Reveal (opacity + scale) is timed by the theme's ui/gentle tokens.

index

number

This visual's item index, matched against the active index to decide whether the sticky panel is foregrounded.

sticky?

boolean = false

Sticky column (true): the panel is absolutely stacked and crossfades in as it becomes the active index. Inline (false, the default): the panel reveals once when it scrolls into view - the layout a consumer uses on mobile and under reduced motion, where there is no sticky column.

className?

string

Merged onto the animating panel - style the card surface (border, fill, padding) here; the component owns only positioning + the reveal motion.

children?

ReactNode | ((state: SpotlightVisualState) => ReactNode)

The product visual. Pass a render function ({ revealed, active }) => … to thread the reveal flag into inner marks, or a plain node when the visual has nothing to sync.

Hooks

useSpotlight

Read the active walkthrough index and the resolved motion mode from the nearest ScrollSpotlight. Use it to render a live position readout (which feature is in focus) and to choose the sticky-vs-inline layout.

useSpotlight(): SpotlightState

Returns

activeItem

number

The active item index (0-based) - the feature the reading band last entered. Drives your position readout and which sticky visual is shown.

activeLine

string

The active line id (the lineId of the last SpotlightCopy to cross the band). Finer-grained than activeItem; usually you want activeItem.

fullMotion

boolean

True when full motion runs (no prefers-reduced-motion): the sticky column and scroll tracking are live. False under calm/off, when the consumer should stack the visuals inline instead.

calm

boolean

True under prefers-reduced-motion with the theme's calm strategy: keep opacity fades, drop travel.

still

boolean

True under prefers-reduced-motion with the theme's off strategy: render the final state with no animation at all.