Control stage
An enlarged control with an animated press.
Customize
import {Scene, Video} from "odori";
import {ControlStage} from "../components/control-stage/control-stage";
<Video>
<Scene id="control-stage" duration="4s">
<ControlStage
controls={[
{
label: "New",
icon: "+",
pressAt: 30
}
]}
charactersPerSecond={14}
size={1}
/>
</Scene>
</Video>Installation
pnpm odori add component control-stagenpx odori add component control-stagebunx odori add component control-stageEdit the source in videos/components/control-stage/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/control-stage/control-stage.tsx
import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type StagedControl = {
/** A button by default; an input is a field that types itself. */
kind?: "button" | "input";
/** The button's text, or the field's placeholder. */
label?: string;
/** A glyph before the label. */
icon?: string;
/** Which surface the control is drawn on. */
tone?: "neutral" | "dark" | "accent" | "positive";
/** Frame the button is pressed. */
pressAt?: number;
/** What types into a field. */
typed?: string;
/** Frame the typing starts. */
typeFrom?: number;
};
export type ControlStageProps = {
/** The controls, laid out in a row. */
controls: StagedControl[];
/** Characters typed per second in a field. */
charactersPerSecond?: number;
/** How large the controls are drawn. A close-up is the point. */
size?: number;
};
/**
* One control, staged large enough to be the subject.
*
* Product video keeps needing the same shot: a single button or field, alone
* on the brand's surface, doing the one thing the sentence just claimed. Shown
* inside a full interface it would be a detail among fifty; shown this size it
* is the argument, and the viewer has nowhere else to look.
*
* The control is drawn the way `chip-caption` draws an inline chip — same
* radius, same tones, same icon-then-label — so a sentence can name a control
* and the next scene can cut to it at scale without the two disagreeing about
* what the product looks like.
*
* Pressing is a frame, not a state a caller toggles: the control settles a
* little smaller and darker for a few frames, which is what a press looks like
* and what a cursor arriving from `cursor-focus` should land on.
*/
export const ControlStage = ({controls, charactersPerSecond = 14, size = 1}: ControlStageProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale() * size;
const surfaceFor = (tone: StagedControl["tone"]) => {
if (tone === "dark") return {background: brand.colors.foreground, color: brand.colors.background, border: "transparent"};
if (tone === "accent") return {background: brand.colors.accent, color: brand.colors.background, border: "transparent"};
if (tone === "positive") return {background: "#0f8a4a", color: brand.colors.foreground, border: "transparent"};
return {
background: brand.colors.background,
color: brand.colors.foreground,
border: `color-mix(in srgb, ${brand.colors.foreground} 14%, transparent)`,
};
};
return (
<Fill style={{alignItems: "center", background: brand.colors.surface, justifyContent: "center"}}>
<div style={{alignItems: "center", display: "flex", gap: 20 * scale}}>
{controls.map((control, index) => {
const surface = surfaceFor(control.tone);
const press =
control.pressAt === undefined
? 0
: interpolate(frame, [control.pressAt, control.pressAt + 4, control.pressAt + 12], [0, 1, 0], {
easing: Easing.standard,
});
if (control.kind === "input") {
const from = control.typeFrom ?? 0;
const text = control.typed ?? "";
const visible = Math.max(0, Math.min(text.length, Math.floor(((frame - from) / 30) * charactersPerSecond)));
const typing = visible > 0 && visible < text.length;
return (
<div
key={index}
style={{
alignItems: "center",
background: brand.colors.background,
border: `${Math.max(1, scale)}px solid color-mix(in srgb, ${brand.colors.foreground} 12%, transparent)`,
borderRadius: 18 * scale,
color: visible > 0 ? brand.colors.foreground : brand.colors.muted,
display: "flex",
fontSize: 34 * scale,
height: 78 * scale,
minWidth: 520 * scale,
padding: `0 ${26 * scale}px`,
}}
>
{visible > 0 ? text.slice(0, visible) : control.label}
{/* The caret blinks on a frame cycle, and rests while typing. */}
<span
style={{
background: brand.colors.foreground,
display: "inline-block",
height: 40 * scale,
marginLeft: 3 * scale,
opacity: typing || Math.floor(frame / 15) % 2 === 0 ? 1 : 0,
width: 2 * scale,
}}
/>
</div>
);
}
return (
<div
key={index}
style={{
alignItems: "center",
background: surface.background,
border: `${Math.max(1, scale)}px solid ${surface.border}`,
borderRadius: 18 * scale,
color: surface.color,
display: "inline-flex",
filter: `brightness(${1 - press * 0.12})`,
fontSize: 34 * scale,
fontWeight: 500,
gap: 12 * scale,
height: 78 * scale,
letterSpacing: "-0.01em",
padding: `0 ${30 * scale}px`,
transform: `scale(${1 - press * 0.03})`,
whiteSpace: "nowrap",
}}
>
{control.icon ? <span style={{fontSize: 30 * scale}}>{control.icon}</span> : null}
{control.label}
</div>
);
})}
</div>
</Fill>
);
};
videos/components/control-stage/control-stage.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ControlStage} from "./control-stage";
export default defineComponentPreview({
title: "Control stage",
category: "Interface/Chrome",
description: "An enlarged control with an animated press.",
component: ControlStage,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
size: {type: "number", defaultValue: 1, min: 0.5, max: 2, step: 0.1},
charactersPerSecond: {type: "number", defaultValue: 14, min: 4, max: 40},
},
examples: [
{
name: "Pressed",
props: {controls: [{label: "New", icon: "+", pressAt: 30}]},
},
{
name: "Field and submit",
props: {
controls: [
{kind: "input", label: "Repository name", typed: "everysphere-test", typeFrom: 8},
{label: "Create Repo", tone: "dark", pressAt: 62},
],
},
},
{
name: "Confirming",
props: {controls: [{label: "Squash and merge", tone: "positive", pressAt: 34}]},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ControlStage} from "../components/control-stage/control-stage";
<Video>
<Scene id="control-stage" duration="4s">
<ControlStage
controls={[
{
label: "New",
icon: "+",
pressAt: 30
}
]}
charactersPerSecond={14}
size={1}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Interface
- Recommended duration
- 120 frames · 4s
- Minimum duration
- 48 frames · 1.6s
- Entrance
- 24 frames
- Exit
- 10 frames
- Reduced motion
- the press lands without the settle
- Requires
- sans font
- Content limits
- controls ≤ 3, label ≤ 22
Props
| Prop | Type | Default | Required |
|---|---|---|---|
controlsThe controls, laid out in a row. | StagedControl[] | — | Yes |
charactersPerSecondCharacters typed per second in a field. | number | 14 | — |
sizeHow large the controls are drawn. A close-up is the point. | number | 1 | — |
Related components
All componentsAccordionSections opening one at a time, the outgoing one closing as the next grows.Browser demoBrowser chrome for deterministic product interface frames.Card deckStacked cards that cycle from front to back.ComboboxA search field that filters a dropdown list.Command menuKeyboard-first action search and selection flow.Context menuThe menu that appears where you clicked, anchored to its own corner.