Command menu
Keyboard-first action search and selection flow.
Customize
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>Installation
pnpm odori add component command-menunpx odori add component command-menubunx odori add component command-menuEdit the source in videos/components/command-menu/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files 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 ${brand.colors.shadow}`,
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: "Interface/Controls",
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"],
},
},
],
});
Add it to 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>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
- 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" | — |
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.Context menuThe menu that appears where you clicked, anchored to its own corner.Control stageAn enlarged control with an animated press.