IX Surfaces

Star on GitHubFeedback
IX SurfacesNative HTML and a hookot-segue

Segue

One scene at a time. When you change it, one hairline crosses the frame carrying the next scene's number and name, and the next scene is already behind it.

Report an issue with Segue

Example

Silence first.

The room before anyone speaks: paper, and the space the words will take.

1 of 3: Overture

Silence first.

The room before anyone speaks: paper, and the space the words will take.

1 of 3: Overture
Wipe

Silence first.

The room before anyone speaks: paper, and the space the words will take.

1 of 3: Overture
Horizon

Silence first.

The room before anyone speaks: paper, and the space the words will take.

1 of 3: Overture
Scrub

Silence first.

Then one line.

Then nothing again.

Scrub, horizon

Silence first.

Then one line.

Then nothing again.

Install

Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base along.

npx shadcn@latest add https://thedirectors.agency/ui/r/segue.json

Usage

This example also uses Button.

npx shadcn@latest add https://thedirectors.agency/ui/r/button.json
segue-example.tsx
"use client"import * as React from "react"import { Button } from "@/components/ui/button"import { Segue, SegueScene } from "@/components/ui/segue"const acts = [["Overture", "Silence first.", "The room before anyone speaks: paper, and the space the words will take."],["Theme", "Then one line.", "A single statement, set large, with nothing beside it to lean on."],["Coda", "Then nothing again.", "The last word stays a moment, and the page is quiet once more."],]function Scenes({ variant, size = "ot-ff" }: { variant?: "wipe" | "horizon"; size?: string }) {const [at, setAt] = React.useState(0)return (<div className="grid gap-8"><Segue value={at} onValueChange={setAt} variant={variant} aria-label="Three scenes">{acts.map(([label, line, note]) => (<SegueScene key={label} label={label} className="grid content-start gap-4 py-10"><p className={size} style={{ margin: 0, color: "var(--ot-ink)" }}>{line}</p><p className="ot-p" style={{ margin: 0, maxWidth: "34ch", color: "var(--ot-graphite)" }}>{note}</p></SegueScene>))}</Segue><div className="flex gap-6"><Button variant="quiet" disabled={at === 0} onClick={() => setAt(at - 1)}>Back</Button><Button variant="quiet" disabled={at === acts.length - 1} onClick={() => setAt(at + 1)}>Next scene</Button></div></div>)}export default function Example() {return (<div className="grid gap-y-24"><Scenes /><Scenes variant="horizon" /></div>)}

Contract

  • Underneath: native, plus a hook. With scrub, it reads the scroll.
  • Anatomy: <div class="ot-segue" data-variant="horizon" data-scrub> holding one .ot-segue-scene per scene (data-current on the one showing, data-label its name; during a crossing data-part="from | to"), each around a SegueScene (.ot-segue-body, a named group when it has a label); then an aria-hidden .ot-segue-line with its .ot-segue-caption (the number, then the name), and a polite .ot-sr that says "2 of 3: Theme" when the scene changes. While crossing the root carries data-phase="cross | scrub", data-edge (left | right | bottom | top, the edge the line comes in from) and --ot-segue-p (0 to 1, registered, so the pressed crossing is one transition).
  • One scene at a time, as a film cuts from shot to shot with a wipe. The scenes stand in one cell, so the frame is as tall as its tallest scene and nothing below it moves. Change value and one hairline in ink crosses the frame (--ot-andante, --ot-breath), carrying the next scene's number and name in small type on its trailing side, as the SHAPES / GRADIENTS rule carries a date and a name. Behind the line the next scene is already there, drifting its last --ot-segue-drift (27px) into place; ahead of it the last scene is pushed on by as much. Going back, the line crosses the other way. A second change mid-crossing arrives at once and crosses again from there. At rest there is no line, no frame and no chrome: only the scene.
  • It is the one hairline scene change for every dissolve (wave-wipe, dither-dissolve, grain-dissolve, pixel-swap): the change is the line, and it moves only when the person does.
  • horizon (data-variant="horizon"): after Eclipse, the soft side of one level line. The line rises from the foot and the next scene comes up under it, its name just under the line at the start; going back, it sets from the head.
  • scrub (scrub, data-scrub): the crossings follow the scroll, after the scrub gather. As the frame's middle rises from four fifths of the way down the view to one fifth (or as far as the page can scroll), it passes through every scene in turn; the line stands as far across as the scroll has gone, stops when the scroll stops and goes back when you scroll back. The scene past halfway is the current one. It is read from the real layout in a frame asked for by the scroll event, which runs after a smooth scroller's own frame, so the line and the page move together. Every scene stays with readers, in order (the hidden ones are transparent, not removed), and there is no announcement.
  • Keyboard: the scenes hold their own controls; the ones not showing are inert and hidden from readers outside a scrub. In a scrub all text remains readable in order, but inactive scenes accept no pointer activation and their controls leave the tab order. If focus is in a scene as it becomes inactive, it moves to the current scene's wrapper without scrolling. The control that changes the scene is yours: a quiet button, a key, an answer.
  • Right to left, the wipe comes in from the right. Reduced motion: the scene changes at once and nothing travels; a scrub changes halfway. Forced colours: the line is CanvasText and the caption drops its halo.

Props

Props of Segue
PropTypeDefaultWhat it does
SegueScenedivA scene. Direct children of Segue only. `label` names it: the hairline carries the name as it crosses, and a labelled scene is a named group for readers.
value / defaultValuenumber0Which scene is showing, from 0. Change it from your own control (a button, a key, an answer) and the line crosses; to an earlier scene it crosses the other way. A second change mid-crossing arrives at once and crosses again from there.
onValueChange(value: number) => voidCalled with the new scene. With `scrub`, called as the scroll brings each scene past halfway.
variant"wipe" | "horizon""wipe"wipe: an upright hairline crosses along the line, from the start (the end, going back; right to left, it mirrors). horizon: after Eclipse, a level hairline rises from the foot and the next scene comes up under it; going back, it sets.
scrubbooleanfalseTie the crossings to the scroll. As the frame's middle rises from four fifths of the way down the view to one fifth, it passes through every scene in turn; the line stands as far across as the scroll has gone, stops when the scroll stops and goes back when you scroll back. Read from the real layout, so it keeps step with smooth scrolling. Every scene stays with readers, in order.
SegueScene labelstringThe name carried by the crossing hairline and the accessible name of the scene's group. Omit it for an unnamed scene.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
SegueThe film editor's wipe; SHAPES / GRADIENTS (a hairline carrying a date and a name)One hairline crosses and the next scene is behind it, its name carried on the line
Horizon segueEclipseThe line rises like a horizon and the next scene comes up under it
Scrub segueThe reader's own pace (the scrub gather)The line stands as far across as the scroll has gone

How it moves

How it moves
ComponentArticulationWhat moves
SegueWipeThe hairline crosses carrying the next scene's number and name; the next scene drifts in behind it and the last is pushed on; back, it crosses the other way
Horizon segueSunriseThe line rises from the foot and the next scene comes up under it; back, it sets
Scrub segueRubato (the reader's tempo)The line travels only as far as the scroll has gone, forwards or back