Combobox
A search field that filters a dropdown list.
Customize
import {Scene, Video} from "odori";
import {Combobox} from "../components/combobox/combobox";
<Video>
<Scene id="combobox" duration="6s">
<Combobox
query="term"
label="Add a component"
chooseAt={96}
charactersPerSecond={12}
/>
</Scene>
</Video>Installation
pnpm odori add component comboboxnpx odori add component comboboxbunx odori add component comboboxEdit the source in videos/components/combobox/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/combobox/combobox.tsx
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from "odori";
export type ComboboxProps = {
/** What gets typed into the field. */
query: string;
/** Everything the list could show. */
options?: string[];
/** The label above the field. */
label?: string;
/** Frame the list settles on one option and it is picked. */
chooseAt?: number;
charactersPerSecond?: number;
};
/**
* A field that filters a list as it is typed into.
*
* The list narrowing is the whole demonstration, so the rows that fall away
* are not simply gone: they collapse, which is what makes the filtering
* legible as a consequence of the typing rather than as three separate
* screens. Matching text is marked in the surviving rows for the same reason.
*/
export const Combobox = ({
query,
options = [
"components/terminal",
"components/terminal-zoom",
"components/title-reveal",
"components/timeline",
"components/toast-stack",
"components/table-focus",
],
label = "Add a component",
chooseAt = 96,
charactersPerSecond = 12,
}: ComboboxProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const px = (value: number) => value * scale;
const typed = useTyping(query, {from: 20, charactersPerSecond});
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const needle = typed.text.toLowerCase();
const chosen = options.find((option) => option.toLowerCase().includes(needle)) ?? options[0];
const picked = interpolate(frame, [chooseAt, chooseAt + 10], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", background: brand.colors.background, justifyContent: "center", padding: px(120)}}>
<div style={{fontFamily: brand.typography.sans, maxWidth: px(860), opacity: enter, width: "100%"}}>
<div style={{color: brand.colors.muted, fontSize: px(20), marginBottom: px(12)}}>{label}</div>
<div
style={{
background: brand.colors.background,
border: `${px(1)}px solid ${typed.length > 0 ? "#3A3A44" : "#1F1F23"}`,
borderRadius: px(12),
color: typed.length > 0 ? "#FAFAFA" : "#6E6E78",
fontFamily: brand.typography.mono,
fontSize: px(26),
padding: `${px(20)}px ${px(22)}px`,
}}
>
{typed.length > 0 ? typed.text : "Search components…"}
{typed.caret ? (
<span
style={{
background: "#FAFAFA",
display: "inline-block",
height: px(26),
marginLeft: px(3),
transform: `translateY(${px(4)}px)`,
width: px(2),
}}
/>
) : null}
</div>
<div
style={{
background: brand.colors.background,
border: `${px(1)}px solid ${brand.colors.border}`,
borderRadius: px(12),
marginTop: px(10),
overflow: "hidden",
padding: px(8),
}}
>
{options.map((option) => {
const matches = option.toLowerCase().includes(needle);
// A row that stops matching collapses rather than vanishing, so
// the list is seen to narrow.
const shown = matches ? 1 : 0;
const isChosen = option === chosen;
return (
<div
key={option}
style={{
background: isChosen && picked > 0 ? `color-mix(in srgb, #26262C ${picked * 100}%, transparent)` : "transparent",
borderRadius: px(8),
color: matches ? "#FAFAFA" : "#6E6E78",
fontFamily: brand.typography.mono,
fontSize: px(22),
height: shown ? px(56) : 0,
lineHeight: `${px(56)}px`,
opacity: shown,
overflow: "hidden",
paddingLeft: px(16),
transition: "none",
}}
>
{option}
</div>
);
})}
</div>
</div>
</Fill>
);
};
videos/components/combobox/combobox.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Combobox} from "./combobox";
export default defineComponentPreview({
title: "Combobox",
category: "Interface/Controls",
description: "A search field that filters a dropdown list.",
component: Combobox,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
query: {type: "text", defaultValue: "term", maxLength: 32},
label: {type: "text", defaultValue: "Add a component", maxLength: 32},
chooseAt: {type: "number", defaultValue: 96, min: 40, max: 200, step: 4},
charactersPerSecond: {type: "number", defaultValue: 12, min: 4, max: 40, step: 1},
},
examples: [
{name: "Default", props: {}},
{name: "One match", props: {query: "toast", chooseAt: 110}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Combobox} from "../components/combobox/combobox";
<Video>
<Scene id="combobox" duration="6s">
<Combobox
query="term"
label="Add a component"
chooseAt={96}
charactersPerSecond={12}
/>
</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
- 180 frames · 6s
- Minimum duration
- 75 frames · 2.5s
- Entrance
- 14 frames
- Exit
- 10 frames
- Reduced motion
- query shown whole, list filtered
- Requires
- sans font
- Content limits
- query ≤ 32, label ≤ 32
Props
| Prop | Type | Default | Required |
|---|---|---|---|
queryWhat gets typed into the field. | string | — | Yes |
optionsEverything the list could show. | string[] | — | — |
labelThe label above the field. | string | "Add a component" | — |
chooseAtFrame the list settles on one option and it is picked. | number | 96 | — |
charactersPerSecond | number | 12 | — |
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.Command menuKeyboard-first action search and selection flow.Context menuThe menu that appears where you clicked, anchored to its own corner.Control stageAn enlarged control with an animated press.