Table focus
Rows and columns with one animated focal selection.
Customize
import {Scene, Video} from "odori";
import {TableFocus} from "../components/table-focus/table-focus";
<Video>
<Scene id="table-focus" duration="4s">
<TableFocus
focus={1}
focusAt={30}
dim={true}
/>
</Scene>
</Video>Installation
pnpm odori add component table-focusnpx odori add component table-focusbunx odori add component table-focusEdit the source in videos/components/table-focus/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/table-focus/table-focus.tsx
import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type TableFocusProps = {
columns?: string[];
rows?: string[][];
/** Which row the camera settles on, by index. */
focus?: number;
/** Frame the focus lands. */
focusAt?: number;
/** Dim the rows that are not the focus. */
dim?: boolean;
};
/**
* Rows and columns with one row that matters.
*
* A table in a product video is never about the table: it is about the one row
* the viewer is supposed to read. So the focus is a first-class prop, the rows
* around it recede, and the highlight arrives on a frame the author chose
* rather than fading in with everything else.
*/
export const TableFocus = ({
columns = ["Deployment", "Environment", "Duration", "Status"],
rows = [],
focus = 1,
focusAt = 30,
dim = true,
}: TableFocusProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const appear = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const landed = interpolate(frame, [focusAt, focusAt + 14], [0, 1], {easing: Easing.standard});
return (
<Fill style={{justifyContent: "center", opacity: appear, padding: 90 * scale}}>
<div style={{display: "flex", flexDirection: "column", gap: 10 * scale}}>
<div style={{display: "flex", gap: 24 * scale, padding: `0 ${28 * scale}px ${14 * scale}px`}}>
{columns.map((column) => (
<span
key={column}
style={{
color: brand.colors.muted,
flex: 1,
fontFamily: brand.typography.sans,
fontSize: 21 * scale,
letterSpacing: "0.06em",
textTransform: "uppercase",
}}
>
{column}
</span>
))}
</div>
{rows.map((row, index) => {
const isFocus = index === focus;
// Everything else recedes rather than the focus getting brighter:
// the row stays the colour it always was, which keeps the table
// looking like a table and not like a slide.
const recede = dim && !isFocus ? 1 - landed * 0.65 : 1;
return (
<div
key={index}
style={{
alignItems: "center",
background: isFocus ? brand.colors.surface : "transparent",
border: `1px solid ${isFocus ? brand.colors.border : "transparent"}`,
borderRadius: 14 * scale,
display: "flex",
gap: 24 * scale,
opacity: recede,
padding: `${22 * scale}px ${28 * scale}px`,
transform: isFocus ? `scale(${1 + landed * 0.012})` : undefined,
}}
>
{row.map((cell, cellIndex) => (
<span
key={cellIndex}
style={{
color: cellIndex === 0 ? brand.colors.foreground : brand.colors.muted,
flex: 1,
fontFamily: cellIndex === 0 ? brand.typography.sans : brand.typography.mono,
fontSize: 26 * scale,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{cell}
</span>
))}
</div>
);
})}
</div>
</Fill>
);
};
videos/components/table-focus/table-focus.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {TableFocus} from "./table-focus";
const ROWS = [
["odori.dev", "production", "48s", "ready"],
["docs-site", "preview", "31s", "ready"],
["studio", "production", "62s", "building"],
["registry", "preview", "12s", "ready"],
];
export default defineComponentPreview({
title: "Table focus",
category: "Interface/Surfaces",
description: "Rows and columns with one animated focal selection.",
component: TableFocus,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
focus: {type: "number", defaultValue: 1, min: 0, max: 3},
focusAt: {type: "number", defaultValue: 30, min: 0, max: 90},
dim: {type: "boolean", defaultValue: true},
},
examples: [
{name: "Default", props: {rows: ROWS}},
{name: "Last row", props: {rows: ROWS, focus: 3}},
{name: "No dimming", props: {rows: ROWS, dim: false}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {TableFocus} from "../components/table-focus/table-focus";
<Video>
<Scene id="table-focus" duration="4s">
<TableFocus
focus={1}
focusAt={30}
dim={true}
/>
</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
- 45 frames · 1.5s
- Entrance
- 14 frames
- Exit
- 0 frames
- Reduced motion
- the focused row is marked without the others dimming
- Requires
- sans font
- Content limits
- columns ≤ 6, rows ≤ 8
Props
| Prop | Type | Default | Required |
|---|---|---|---|
columns | string[] | ["Deployment" | — |
rows | string[][] | [] | — |
focusWhich row the camera settles on, by index. | number | 1 | — |
focusAtFrame the focus lands. | number | 30 | — |
dimDim the rows that are not the focus. | boolean | true | — |
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.