Dropdown menu
A dropdown menu with animated item selection.
Customize
import {Scene, Video} from "odori";
import {DropdownMenu} from "../components/dropdown-menu/dropdown-menu";
<Video>
<Scene id="dropdown-menu" duration="5s">
<DropdownMenu
trigger="Export"
chosen={1}
chooseAt={70}
/>
</Scene>
</Video>Installation
pnpm odori add component dropdown-menunpx odori add component dropdown-menubunx odori add component dropdown-menuEdit the source in videos/components/dropdown-menu/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/dropdown-menu/dropdown-menu.tsx
import {Easing, Fill, interpolate, spring, useBrand, useDesignScale, useFrame, useVideo} from "odori";
export type DropdownMenuProps = {
/** The control the menu hangs from. */
trigger?: string;
/** Items in the menu. A leading "-" draws a separator above it. */
items?: string[];
/** Which item the pointer lands on, zero based. */
chosen?: number;
/** Frame the menu opens. */
openAt?: number;
/** Frame the choice is made. */
chooseAt?: number;
};
/**
* A menu opening and an item being chosen.
*
* The menu scales from the corner it is anchored to, which is what says it
* belongs to the button rather than to the page. The highlight then walks to
* the chosen row instead of appearing on it, because a pointer that teleports
* reads as a state change and a pointer that travels reads as a decision.
*/
export const DropdownMenu = ({
trigger = "Export",
items = ["MP4 video", "WebM with alpha", "ProRes 4444", "-PNG sequence", "-Copy frame"],
chosen = 1,
openAt = 22,
chooseAt = 70,
}: DropdownMenuProps) => {
const frame = useFrame();
const {fps} = useVideo();
const brand = useBrand();
const scale = useDesignScale();
const px = (value: number) => value * scale;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const opened = spring({frame, fps, delayInFrames: openAt, stiffness: 220, damping: 18});
// The highlight starts at the top and travels down to the chosen row.
const walk = interpolate(frame, [openAt + 10, chooseAt], [0, chosen], {easing: Easing.standard});
const pressed = interpolate(frame, [chooseAt, chooseAt + 8], [0, 1], {easing: Easing.standard});
const rowHeight = 58;
return (
<Fill style={{alignItems: "center", background: brand.colors.background, justifyContent: "center", padding: px(120)}}>
<div style={{fontFamily: brand.typography.sans, opacity: enter, position: "relative"}}>
<span
style={{
background: frame >= openAt ? "#1C1C20" : "transparent",
border: `${px(1)}px solid ${brand.colors.border}`,
borderRadius: px(10),
color: brand.colors.foreground,
display: "inline-block",
fontSize: px(24),
padding: `${px(14)}px ${px(26)}px`,
}}
>
{trigger}
</span>
<div
style={{
background: brand.colors.surface,
border: `${px(1)}px solid ${brand.colors.border}`,
borderRadius: px(12),
boxShadow: `0 ${px(24)}px ${px(70)}px ${brand.colors.shadow}`,
marginTop: px(10),
minWidth: px(380),
opacity: opened,
overflow: "hidden",
padding: `${px(8)}px`,
transform: `scale(${0.94 + opened * 0.06})`,
transformOrigin: "0% 0%",
}}
>
{items.map((item, index) => {
const separated = item.startsWith("-");
const label = separated ? item.slice(1) : item;
const near = 1 - Math.min(1, Math.abs(walk - index));
return (
<div key={label}>
{separated ? <div style={{background: brand.colors.surface, height: px(1), margin: `${px(6)}px 0`}} /> : null}
<div
style={{
background: `color-mix(in srgb, #26262C ${near * 100}%, transparent)`,
borderRadius: px(8),
color: near > 0.5 ? "#FAFAFA" : "#9A9AA2",
fontSize: px(22),
padding: `${px(14)}px ${px(16)}px`,
transform: `scale(${index === chosen ? 1 - pressed * 0.01 : 1})`,
}}
>
{label}
</div>
</div>
);
})}
</div>
</div>
</Fill>
);
};
videos/components/dropdown-menu/dropdown-menu.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {DropdownMenu} from "./dropdown-menu";
export default defineComponentPreview({
title: "Dropdown menu",
category: "Interface/Controls",
description: "A dropdown menu with animated item selection.",
component: DropdownMenu,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
trigger: {type: "text", defaultValue: "Export", maxLength: 24},
chosen: {type: "number", defaultValue: 1, min: 0, max: 6, step: 1},
chooseAt: {type: "number", defaultValue: 70, min: 30, max: 150, step: 5},
},
examples: [
{name: "Default", props: {}},
{name: "Last item", props: {chosen: 4, chooseAt: 84}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {DropdownMenu} from "../components/dropdown-menu/dropdown-menu";
<Video>
<Scene id="dropdown-menu" duration="5s">
<DropdownMenu
trigger="Export"
chosen={1}
chooseAt={70}
/>
</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
- 14 frames
- Exit
- 10 frames
- Reduced motion
- menu shown open, no walk
- Requires
- sans font
- Content limits
- trigger ≤ 24
Props
| Prop | Type | Default | Required |
|---|---|---|---|
triggerThe control the menu hangs from. | string | "Export" | — |
itemsItems in the menu. A leading "-" draws a separator above it. | string[] | ["MP4 video" | — |
chosenWhich item the pointer lands on, zero based. | number | 1 | — |
openAtFrame the menu opens. | number | 22 | — |
chooseAtFrame the choice is made. | number | 70 | — |
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.