Story

A full-screen sequence of panels over a darkened page. A dialog underneath — same overlay, focus trap, Escape and scroll lock — but it advances on its own, and everything else follows from that.

src/components/ui/story.tsx

About

From the component's own source, where it was written.

A full-screen sequence of panels over a darkened page — the stories pattern.

It is a dialog underneath: same overlay, same focus trap, same Escape, same scroll lock. What makes it a story rather than a modal is that it **advances on its own**, and everything below follows from that.

**The timer is wall-clock, and it does not use requestAnimationFrame.** rAF does not fire at all in a hidden tab, so a story in a background tab simply stops — and a counter decremented on an interval drifts instead. Elapsed time is recomputed from Date.now() on a coarse interval, which is correct whatever the interval actually did, and a CSS transition on the bar makes 20fps of state look continuous.

**It pauses on press, and on focus.** Holding to read is the gesture everyone already knows. Focus matters more: a panel containing a link or a button is unusable if it advances while you are tabbing through it, so any focus inside the content stops the clock.

**prefers-reduced-motion removes the animation, not the story.** An earlier version froze the timer outright, which meant anyone with that setting on opened a story that silently never advanced and said nothing about why. Now the bar jumps between steps instead of gliding, and the panels still turn.

**There is a real pause button.** Holding to pause is the expected gesture but it is neither discoverable nor reachable from a keyboard, and auto-advancing content has to be pausable by everyone.

Panels are ReactNode, so a story panel is not limited to an image — a chart, a form, a changelog entry all work.

Install

npx astralyx-ui add story

Usage

import { Story } from '@/components/ui/story'

<Story
  open={open}
  onOpenChange={setOpen}
  panels={[{ id: '1', content: <Announcement /> }]}
  header={<Avatar name="Astralyx" size="sm" />}
/>

Composer

Change the props and the source below follows.

Escape closes it, arrows move between panels, and holding pauses.

Props
tsx
<Story
  open={open}
  onOpenChange={setOpen}
  panels={panels}
  autoPlay={true}
  duration={4000}
/>

Examples

A three-panel story

Manual advance

API

Generated from the source, so it cannot fall behind the code. One table per exported component, then hooks, functions and types.

Story

Extends Omit<ComponentProps<'div'>, 'content' | 'onSelect'>.

PropTypeDefaultDescription
openbooleanrequired—
onOpenChange(open: boolean) => voidrequired—
panelsStoryPanel[]required{ id, content, duration? }. Content is a ReactNode, so a panel is not limited to an image — a chart, a form or a changelog entry all work.
indexnumber—Controlled position. Uncontrolled by default, resetting to the first panel each time it opens.
onIndexChange(index: number) => void—Controlled position. Uncontrolled by default, resetting to the first panel each time it opens.
durationnumber5000Default panel duration.
autoPlaybooleantrueAdvance automatically. Off makes it a plain panel viewer.
onFinish() => void—After the last panel. Defaults to closing.
headerReactNode—Header slot — an avatar, a name, a timestamp.
closeLabelstring'Close'—
previousLabelstring'Previous'—
nextLabelstring'Next'—
pausedLabelstring'Paused'—
pauseLabelstring'Pause'—
resumeLabelstring'Resume'—
widthnumber | string— / fills maxHeight / 9:16One dimension plus a ratio, never both: with both set, max-height biting on a short window clamps the height alone and squashes the shape. By default it fills the available height, since a fixed 400px box on a desktop reads as a dialog rather than a story.
heightnumber | string— / fills maxHeight / 9:16One dimension plus a ratio, never both: with both set, max-height biting on a short window clamps the height alone and squashes the shape. By default it fills the available height, since a fixed 400px box on a desktop reads as a dialog rather than a story.
rationumber9 / 16One dimension plus a ratio, never both: with both set, max-height biting on a short window clamps the height alone and squashes the shape. By default it fills the available height, since a fixed 400px box on a desktop reads as a dialog rather than a story.
maxWidthnumber | string'92vw'Caps, so the fixed box still fits a small window.
maxHeightnumber | string'88vh'Caps, so the fixed box still fits a small window.

Types

StoryPanel

A full-screen sequence of panels over a darkened page — the stories pattern.

FieldTypeDescription
idstring—
contentReactNode—
duration?numberMilliseconds. Falls back to the story-level duration.

Behaviour

TopicDetailDescription
timingwall clockProgress is recomputed from the animation frame rather than counted down on an interval. A story left in a background tab would otherwise race through every panel and be finished when you came back.
pausingpress, focus, reduced motionHolding pauses — the gesture from the apps this comes from. Focus inside a panel pauses too, or a panel with a link is unusable while tabbing. prefers-reduced-motion starts it stopped.
keyboardEscape / arrowsThe tap zones are real buttons, so the sequence is navigable without a pointer.