command-menu
Keyboard-first action search and selection flow.
import {Scene, Video} from "odori";
import {CommandMenu} from "../components/command-menu/command-menu";
<Video>
<Scene id="command-menu" duration="5s">
<CommandMenu
query="exp"
items={[
"Export video",
"Export still",
"Open Studio",
"Run contract tests"
]}
selectedIndex={0}
/>
</Scene>
</Video>The same runtime that renders the export. Scrub to any frame, switch format, and edit the fixture props.
Installation
pnpm odori add @odori/command-menunpx odori add @odori/command-menubunx odori add @odori/command-menuSource is copied into videos/components/command-menu/ and belongs to your repository. odori diff compares it with upstream later; odori update applies the change you accept. It has no registry dependencies.
Copy and paste the following code into your project.
videos/components/command-menu/command-menu.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type CommandMenuProps = {
/** What the operator types, revealed character by character. */
query: string;
items: string[];
/** Index the selection lands on. */
selectedIndex?: number;
placeholder?: string;
};
/**
* A keyboard-first palette: the query types itself, the list narrows to what
* matches, and the selection settles on one action. Filtering is computed from
* the typed prefix, so the list on screen is the list the query produces.
*/
export const CommandMenu = ({query, items, selectedIndex = 0, placeholder = "Search actions"}: CommandMenuProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const open = interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard});
const typed = query.slice(0, Math.max(0, Math.floor((frame - 14) / 2)));
const matches = items.filter((item) => item.toLowerCase().includes(typed.toLowerCase()));
const selectAt = 16 + query.length * 2 + 12;
const selection = interpolate(frame, [selectAt, selectAt + 12], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", justifyContent: "center", padding: 90 * scale}}>
<div
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 26 * scale,
boxShadow: `0 ${40 * scale}px ${120 * scale}px rgba(0,0,0,0.35)`,
opacity: open,
overflow: "hidden",
transform: `translateY(${(1 - open) * 26 * scale}px) scale(${0.97 + open * 0.03})`,
width: 1080 * scale,
}}
>
<div
style={{
alignItems: "center",
borderBottom: `1px solid ${brand.colors.border}`,
display: "flex",
gap: 14 * scale,
padding: `${26 * scale}px ${30 * scale}px`,
}}
>
<span style={{color: brand.colors.muted, fontFamily: brand.typography.mono, fontSize: 32 * scale}}>{">"}</span>
<span style={{fontSize: 36 * scale}}>
{typed || <span style={{color: brand.colors.muted}}>{placeholder}</span>}
</span>
<span
style={{
background: brand.colors.accent,
height: 36 * scale,
opacity: frame % 30 < 15 ? 1 : 0,
width: 3 * scale,
}}
/>
</div>
<div style={{padding: `${14 * scale}px ${12 * scale}px`}}>
{matches.map((item, index) => {
const active = index === Math.min(selectedIndex, matches.length - 1);
return (
<div
key={item}
style={{
background: active
? `color-mix(in srgb, ${brand.colors.accent} ${selection * 14}%, transparent)`
: "transparent",
borderRadius: 14 * scale,
color: active ? brand.colors.foreground : brand.colors.muted,
fontSize: 32 * scale,
padding: `${16 * scale}px ${20 * scale}px`,
}}
>
{item}
</div>
);
})}
</div>
</div>
</Fill>
);
};
videos/components/command-menu/command-menu.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {CommandMenu} from "./command-menu";
export default defineComponentPreview({
title: "Command menu",
category: "Developer proof",
description: "Keyboard-first action search and selection flow.",
component: CommandMenu,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
query: {type: "text", defaultValue: "exp", maxLength: 24},
selectedIndex: {type: "number", defaultValue: 0, min: 0, max: 5},
},
examples: [
{
name: "Export",
props: {
query: "exp",
items: ["Export video", "Export still", "Open Studio", "Run contract tests"],
},
},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {CommandMenu} from "../components/command-menu/command-menu";
<Video>
<Scene id="command-menu" duration="5s">
<CommandMenu
query="exp"
items={[
"Export video",
"Export still",
"Open Studio",
"Run contract tests"
]}
selectedIndex={0}
/>
</Scene>
</Video>Nothing else to install.
The component imports only from odori, so there are no dependencies to add and no import paths to rewrite.
Timing contract
Every registry component declares how it behaves in time.odori test fails a cut that violates these.
- Family
- Developer proof
- Aspect ratios
- 16:9 · 1:1
- Recommended duration
- 150 frames · 5s
- Minimum duration
- 75 frames · 2.5s
- Entrance
- 18 frames
- Exit
- 0 frames
- Reduced motion
- the query and selection are shown resolved
- Requires
- mono font
- Content limits
- items ≤ 6, itemLength ≤ 32, query ≤ 24
Props
| Prop | Type | Default | Required |
|---|---|---|---|
queryWhat the operator types, revealed character by character. | string | — | Yes |
items | string[] | — | Yes |
selectedIndexIndex the selection lands on. | number | 0 | — |
placeholder | string | "Search actions" | — |