Context menu
The menu that appears where you clicked, anchored to its own corner.
Customize
import {Scene, Video} from "odori";
import {ContextMenu} from "../components/context-menu/context-menu";
<Video>
<Scene id="context-menu" duration="4s">
<ContextMenu
items={[
{
label: "Ask Cursor",
icon: "✦"
},
{
label: "Copy permalink",
icon: "⧉"
},
{
label: "Copy code",
icon: "⌘"
}
]}
x={0.42}
y={0.3}
openAt={12}
chooseAt={44}
chosen={0}
/>
</Scene>
</Video>Installation
pnpm odori add component context-menunpx odori add component context-menubunx odori add component context-menuEdit the source in videos/components/context-menu/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/context-menu/context-menu.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type ContextMenuItem = {
label: string;
/** A glyph before the label. */
icon?: string;
};
export type ContextMenuProps = {
/** The surface the menu opens over. */
children?: ReactNode;
items: ContextMenuItem[];
/** Where the menu's corner sits, as a fraction of the frame. */
x?: number;
y?: number;
/** Frame the menu opens on. */
openAt?: number;
/** Frame an item is chosen. Counts from the top, zero based. */
chooseAt?: number;
/** Which item is chosen. */
chosen?: number;
};
/**
* The menu that appears where you clicked.
*
* A command palette answers "what can I do anywhere"; this answers "what can I
* do to this", which is why it is anchored to a point rather than centred, and
* why it has no query. Product video needs it constantly: the moment a demo
* right-clicks a line of code or a row in a table, the menu is the whole
* point of the shot.
*
* It opens from its own corner rather than fading in place, because that is
* where the click was, and the corner is what tells the viewer the menu
* belongs to the thing underneath rather than to the window.
*/
export const ContextMenu = ({
children,
items,
x = 0.42,
y = 0.3,
openAt = 12,
chooseAt,
chosen = 0,
}: ContextMenuProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const open = interpolate(frame, [openAt, openAt + 10], [0, 1], {easing: Easing.standard});
const highlight = chooseAt === undefined ? 0 : interpolate(frame, [chooseAt - 6, chooseAt], [0, 1], {easing: Easing.standard});
const press = chooseAt === undefined ? 0 : interpolate(frame, [chooseAt, chooseAt + 5, chooseAt + 12], [0, 1, 0], {easing: Easing.standard});
return (
<Fill style={{alignItems: "stretch", justifyContent: "stretch", padding: 0, position: "relative"}}>
{children}
<div
style={{
background: brand.colors.background,
border: `${Math.max(1, scale)}px solid color-mix(in srgb, ${brand.colors.foreground} 12%, transparent)`,
borderRadius: 16 * scale,
boxShadow: `0 ${30 * scale}px ${70 * scale}px color-mix(in srgb, ${brand.colors.foreground} 18%, transparent)`,
left: `${x * 100}%`,
opacity: open,
padding: 8 * scale,
position: "absolute",
top: `${y * 100}%`,
// Anchored at the corner the click happened on.
transform: `scale(${0.92 + open * 0.08})`,
transformOrigin: "top left",
}}
>
{items.map((item, index) => {
const active = index === chosen ? highlight : 0;
return (
<div
key={item.label}
style={{
alignItems: "center",
background:
active > 0
? `color-mix(in srgb, ${brand.colors.foreground} ${6 + active * 6 + press * 4}%, transparent)`
: "transparent",
borderRadius: 10 * scale,
color: brand.colors.foreground,
display: "flex",
fontSize: 26 * scale,
gap: 14 * scale,
padding: `${12 * scale}px ${18 * scale}px`,
whiteSpace: "nowrap",
}}
>
{item.icon ? (
<span style={{color: brand.colors.muted, fontSize: 24 * scale}}>{item.icon}</span>
) : null}
{item.label}
</div>
);
})}
</div>
</Fill>
);
};
videos/components/context-menu/context-menu.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ContextMenu} from "./context-menu";
export default defineComponentPreview({
title: "Context menu",
category: "Interface/Controls",
description: "The menu that appears where you clicked, anchored to its own corner.",
component: ContextMenu,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
x: {type: "number", defaultValue: 0.42, min: 0, max: 0.8, step: 0.02},
y: {type: "number", defaultValue: 0.3, min: 0, max: 0.8, step: 0.02},
openAt: {type: "number", defaultValue: 12, min: 0, max: 90},
},
examples: [
{
name: "On a line of code",
props: {
items: [{label: "Ask Cursor", icon: "✦"}, {label: "Copy permalink", icon: "⧉"}, {label: "Copy code", icon: "⌘"}],
chooseAt: 44,
chosen: 0,
},
},
{
name: "Short menu",
props: {items: [{label: "Rename"}, {label: "Duplicate"}, {label: "Delete"}], chooseAt: 40, chosen: 2},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ContextMenu} from "../components/context-menu/context-menu";
<Video>
<Scene id="context-menu" duration="4s">
<ContextMenu
items={[
{
label: "Ask Cursor",
icon: "✦"
},
{
label: "Copy permalink",
icon: "⧉"
},
{
label: "Copy code",
icon: "⌘"
}
]}
x={0.42}
y={0.3}
openAt={12}
chooseAt={44}
chosen={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
- 120 frames · 4s
- Minimum duration
- 48 frames · 1.6s
- Entrance
- 24 frames
- Exit
- 10 frames
- Reduced motion
- the menu appears without scaling from its corner
- Requires
- sans font
- Content limits
- items ≤ 6, label ≤ 24
Props
| Prop | Type | Default | Required |
|---|---|---|---|
childrenThe surface the menu opens over. | ReactNode | — | — |
items | ContextMenuItem[] | — | Yes |
xWhere the menu's corner sits, as a fraction of the frame. | number | 0.42 | — |
y | number | 0.3 | — |
openAtFrame the menu opens on. | number | 12 | — |
chooseAtFrame an item is chosen. Counts from the top, zero based. | number | — | — |
chosenWhich item is chosen. | number | 0 | — |
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.Control stageAn enlarged control with an animated press.