Settings toggles
Switches flipping one at a time, the knob travelling and the track filling behind it.
Customize
import {Scene, Video} from "odori";
import {SettingsToggles} from "../components/settings-toggles/settings-toggles";
<Video>
<Scene id="settings-toggles" duration="5s">
<SettingsToggles
title="Render"
/>
</Scene>
</Video>Installation
pnpm odori add component settings-togglesnpx odori add component settings-togglesbunx odori add component settings-togglesEdit the source in videos/components/settings-toggles/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/settings-toggles/settings-toggles.tsx
import {Easing, Fill, interpolate, spring, useBrand, useDesignScale, useFrame, useVideo} from "odori";
export type SettingsRow = {label: string; hint?: string; on?: boolean; flipsAt?: number};
export type SettingsTogglesProps = {
title?: string;
rows?: SettingsRow[];
};
/**
* Switches being flipped in a settings panel.
*
* The knob travels and the track fills behind it, and the row's description
* settles a frame later, which is the order the eye wants: the thing you
* touched, then what it did. Flipping several at once would read as a
* configuration screenshot rather than as someone changing their mind.
*/
export const SettingsToggles = ({
title = "Render",
rows = [
{label: "Reuse cached chunks", hint: "Skips frames that have not changed", on: true},
{label: "Skip unchanged frames", hint: "Captures only what moved", on: false, flipsAt: 34},
{label: "Normalize loudness", hint: "Mixes to the brand's target on export", on: false, flipsAt: 62},
{label: "Open when finished", hint: "Reveals the file in your Downloads", on: false},
],
}: SettingsTogglesProps) => {
const frame = useFrame();
const {fps} = useVideo();
const brand = useBrand();
const scale = useDesignScale();
const px = (value: number) => value * scale;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", background: brand.colors.background, justifyContent: "center", padding: px(120)}}>
<div
style={{
background: brand.colors.background,
border: `${px(1)}px solid ${brand.colors.border}`,
borderRadius: px(16),
fontFamily: brand.typography.sans,
maxWidth: px(1000),
opacity: enter,
transform: `translateY(${(1 - enter) * px(14)}px)`,
width: "100%",
}}
>
<div
style={{
borderBottom: `${px(1)}px solid ${brand.colors.border}`,
color: brand.colors.foreground,
fontSize: px(26),
fontWeight: 500,
padding: `${px(24)}px ${px(30)}px`,
}}
>
{title}
</div>
{rows.map((row, index) => {
const flip =
row.flipsAt === undefined
? row.on
? 1
: 0
: spring({frame, fps, delayInFrames: row.flipsAt, stiffness: 260, damping: 20});
const on = row.on ? 1 - flip * 0 : flip;
return (
<div
key={row.label}
style={{
alignItems: "center",
borderTop: index === 0 ? "none" : `${px(1)}px solid #16161A`,
display: "flex",
gap: px(24),
padding: `${px(22)}px ${px(30)}px`,
}}
>
<div style={{minWidth: 0}}>
<div style={{color: brand.colors.foreground, fontSize: px(23)}}>{row.label}</div>
{row.hint ? (
<div
style={{
color: brand.colors.muted,
fontSize: px(19),
marginTop: px(5),
// A beat behind the knob: the thing you touched, then
// what it did.
opacity: row.flipsAt === undefined ? 1 : 0.55 + on * 0.45,
}}
>
{row.hint}
</div>
) : null}
</div>
<span
style={{
background: `color-mix(in srgb, #FAFAFA ${on * 100}%, #26262C)`,
borderRadius: px(999),
display: "inline-block",
flex: "none",
height: px(36),
marginLeft: "auto",
position: "relative",
width: px(64),
}}
>
<span
style={{
background: on > 0.5 ? "#09090B" : "#8A8A93",
borderRadius: px(999),
height: px(26),
left: px(5 + on * 28),
position: "absolute",
top: px(5),
width: px(26),
}}
/>
</span>
</div>
);
})}
</div>
</Fill>
);
};
videos/components/settings-toggles/settings-toggles.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {SettingsToggles} from "./settings-toggles";
export default defineComponentPreview({
title: "Settings toggles",
category: "Interface/Controls",
description: "Switches flipping one at a time, the knob travelling and the track filling behind it.",
component: SettingsToggles,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {title: {type: "text", defaultValue: "Render", maxLength: 24}},
examples: [
{name: "Default", props: {}},
{
name: "One switch",
props: {
title: "Preferences",
rows: [{label: "Open Studio on start", hint: "Skips the terminal step", on: false, flipsAt: 30}],
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {SettingsToggles} from "../components/settings-toggles/settings-toggles";
<Video>
<Scene id="settings-toggles" duration="5s">
<SettingsToggles
title="Render"
/>
</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
- 150 frames · 5s
- Minimum duration
- 75 frames · 2.5s
- Entrance
- 14 frames
- Exit
- 10 frames
- Reduced motion
- switches shown in final state
- Requires
- sans font
- Content limits
- title ≤ 24
Props
| Prop | Type | Default | Required |
|---|---|---|---|
title | string | "Render" | — |
rows | SettingsRow[] | — | — |
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.