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.tsxAbout
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.
<Story
open={open}
onOpenChange={setOpen}
panels={panels}
autoPlay={true}
duration={4000}
/>Examples
A three-panel story
<Story open={open} onOpenChange={setOpen} panels={panels} />Manual advance
<Story open={open} onOpenChange={setOpen} panels={panels} autoPlay={false} />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'>.
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | required | — |
| onOpenChange | (open: boolean) => void | required | — |
| panels | StoryPanel[] | 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. |
| index | number | — | 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. |
| duration | number | 5000 | Default panel duration. |
| autoPlay | boolean | true | Advance automatically. Off makes it a plain panel viewer. |
| onFinish | () => void | — | After the last panel. Defaults to closing. |
| header | ReactNode | — | Header slot — an avatar, a name, a timestamp. |
| closeLabel | string | 'Close' | — |
| previousLabel | string | 'Previous' | — |
| nextLabel | string | 'Next' | — |
| pausedLabel | string | 'Paused' | — |
| pauseLabel | string | 'Pause' | — |
| resumeLabel | string | 'Resume' | — |
| width | number | string | — / fills maxHeight / 9:16 | One 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. |
| height | number | string | — / fills maxHeight / 9:16 | One 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. |
| ratio | number | 9 / 16 | One 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. |
| maxWidth | number | string | '92vw' | Caps, so the fixed box still fits a small window. |
| maxHeight | number | 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.
| Field | Type | Description |
|---|---|---|
| id | string | — |
| content | ReactNode | — |
| duration? | number | Milliseconds. Falls back to the story-level duration. |
Behaviour
| Topic | Detail | Description |
|---|---|---|
| timing | wall clock | Progress 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. |
| pausing | press, focus, reduced motion | Holding 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. |
| keyboard | Escape / arrows | The tap zones are real buttons, so the sequence is navigable without a pointer. |