Tabs
Tabs switching with the indicator travelling between them.
Customize
import {Scene, Video} from "odori";
import {TabsSwitch} from "../components/tabs-switch/tabs-switch";
<Video>
<Scene id="tabs-switch" duration="8s">
<TabsSwitch
holdFrames={50}
/>
</Scene>
</Video>Installation
pnpm odori add component tabs-switchnpx odori add component tabs-switchbunx odori add component tabs-switchEdit the source in videos/components/tabs-switch/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/tabs-switch/tabs-switch.tsx
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from "odori";
export type TabsSwitchProps = {
/** The tabs across the top. */
tabs?: string[];
/** Which tab is selected, in order, one per beat. */
order?: number[];
/** Frames each tab is held before the next. */
holdFrames?: number;
/** Lines of content under each tab, keyed by tab index. */
panels?: string[][];
};
/**
* Tabs switching, with the indicator travelling rather than jumping.
*
* The indicator is the component. A tab set where the underline cuts from one
* position to the next reads as two screenshots; one where it slides reads as
* a control being used, and that is the entire difference between showing a
* product and showing someone using a product.
*/
export const TabsSwitch = ({
tabs = ["Overview", "Deployments", "Analytics", "Settings"],
order = [0, 1, 2],
holdFrames = 50,
panels = [
["Production", "Ready · 2h ago", "odori.dev"],
["12 deployments this week", "Latest: 4m 12s", "All checks passed"],
["48.2k requests", "p95 118ms", "0 errors"],
["Team access", "Environment variables", "Domains"],
],
}: TabsSwitchProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const px = (value: number) => value * scale;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
// A continuous position through the order, so the indicator is always
// between two real tabs rather than snapping to one.
const step = Math.max(1, holdFrames);
const at = Math.max(0, (frame - 20) / step);
const index = Math.min(order.length - 1, Math.floor(at));
const next = Math.min(order.length - 1, index + 1);
const travel = interpolate(at - index, [0.55, 1], [0, 1], {easing: Easing.standard});
const position = order[index] + (order[next] - order[index]) * travel;
const active = travel > 0.5 ? order[next] : order[index];
const width = 100 / tabs.length;
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(1180),
opacity: enter,
transform: `translateY(${(1 - enter) * px(14)}px)`,
width: "100%",
}}
>
<div style={{borderBottom: `${px(1)}px solid ${brand.colors.border}`, position: "relative"}}>
<div style={{display: "flex"}}>
{tabs.map((tab, tabIndex) => (
<span
key={tab}
style={{
color: tabIndex === active ? "#FAFAFA" : "#8A8A93",
fontSize: px(24),
fontWeight: tabIndex === active ? 500 : 400,
padding: `${px(22)}px 0`,
textAlign: "center",
width: `${width}%`,
}}
>
{tab}
</span>
))}
</div>
<span
style={{
background: "#FAFAFA",
bottom: 0,
height: px(2),
left: `${position * width}%`,
position: "absolute",
width: `${width}%`,
}}
/>
</div>
<div style={{display: "grid", gap: px(16), padding: `${px(34)}px ${px(34)}px ${px(40)}px`}}>
{(panels[active] ?? []).map((line, lineIndex) => (
<div
key={line}
style={{
color: lineIndex === 0 ? "#FAFAFA" : "#8A8A93",
fontSize: lineIndex === 0 ? px(30) : px(22),
fontWeight: lineIndex === 0 ? 500 : 400,
// Content fades in behind the indicator rather than with it, so
// the eye follows one thing and then reads the other.
opacity: interpolate(Math.abs(at - Math.round(at)), [0.5, 0.25], [0, 1], {easing: Easing.standard}),
}}
>
{line}
</div>
))}
</div>
</div>
</Fill>
);
};
videos/components/tabs-switch/tabs-switch.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {TabsSwitch} from "./tabs-switch";
export default defineComponentPreview({
title: "Tabs",
category: "Interface/Controls",
description: "Tabs switching with the indicator travelling between them.",
component: TabsSwitch,
canvas: {width: 1920, height: 1080, duration: "8s"},
controls: {holdFrames: {type: "number", defaultValue: 50, min: 20, max: 120, step: 5}},
examples: [
{name: "Default", props: {}},
{name: "Two tabs", props: {tabs: ["Preview", "Code"], order: [0, 1], panels: [["The component, playing"], ["The source you install"]]}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {TabsSwitch} from "../components/tabs-switch/tabs-switch";
<Video>
<Scene id="tabs-switch" duration="8s">
<TabsSwitch
holdFrames={50}
/>
</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
- 240 frames · 8s
- Minimum duration
- 90 frames · 3s
- Entrance
- 14 frames
- Exit
- 10 frames
- Reduced motion
- final tab shown, indicator placed
- Requires
- sans font
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
tabsThe tabs across the top. | string[] | ["Overview" | — |
orderWhich tab is selected, in order, one per beat. | number[] | [0 | — |
holdFramesFrames each tab is held before the next. | number | 50 | — |
panelsLines of content under each tab, keyed by tab index. | string[][] | — | — |
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.