Sheet
A side panel arriving while the page behind it gives way.
Customize
import {Scene, Video} from "odori";
import {SheetPanel} from "../components/sheet-panel/sheet-panel";
<Video>
<Scene id="sheet-panel" duration="5s">
<SheetPanel
title="Export settings"
description="Applies to this video only."
action="Export video"
side="right"
/>
</Scene>
</Video>Installation
pnpm odori add component sheet-panelnpx odori add component sheet-panelbunx odori add component sheet-panelEdit the source in videos/components/sheet-panel/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/sheet-panel/sheet-panel.tsx
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from "odori";
export type SheetPanelProps = {
/** The heading inside the panel. */
title?: string;
/** The line under it. */
description?: string;
/** Labelled rows the panel holds. */
fields?: Array<{label: string; value: string}>;
/** The button at the foot of the panel. */
action?: string;
/** The side it comes from. */
side?: "right" | "left";
/** Frame the panel starts opening. */
openAt?: number;
};
/**
* A side panel arriving over the page.
*
* The panel slides and the page behind it dims and recedes a fraction. That
* second part is what sells it: a panel that arrives over a page which does
* not react reads as two layers pasted together, and one where the page gives
* way reads as depth.
*/
export const SheetPanel = ({
title = "Export settings",
description = "Applies to this video only.",
fields = [
{label: "Format", value: "MP4"},
{label: "Quality", value: "Studio"},
{label: "Size", value: "1920 × 1080"},
{label: "Destination", value: "Downloads"},
],
action = "Export video",
side = "right",
openAt = 20,
}: SheetPanelProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const px = (value: number) => value * scale;
const enter = interpolate(frame, [0, 12], [0, 1], {easing: Easing.standard});
const open = interpolate(frame, [openAt, openAt + 26], [0, 1], {easing: Easing.standard});
const shift = (1 - open) * 100 * (side === "right" ? 1 : -1);
return (
<Fill style={{background: brand.colors.background, padding: px(70)}}>
<div
style={{
borderRadius: px(16),
fontFamily: brand.typography.sans,
height: "100%",
opacity: enter,
overflow: "hidden",
position: "relative",
width: "100%",
}}
>
{/* The page, giving way. */}
<div
style={{
background: brand.colors.background,
border: `${px(1)}px solid ${brand.colors.border}`,
borderRadius: px(16),
height: "100%",
padding: px(46),
transform: `scale(${1 - open * 0.02})`,
width: "100%",
}}
>
<div style={{color: brand.colors.foreground, fontSize: px(34), fontWeight: 500}}>Launch film</div>
<div style={{color: brand.colors.muted, fontSize: px(22), marginTop: px(12)}}>1920 × 1080 · 30fps · 12.00s</div>
<div style={{display: "grid", gap: px(14), marginTop: px(36)}}>
{[0, 1, 2].map((row) => (
<div key={row} style={{background: brand.colors.surface, borderRadius: px(10), height: px(70)}} />
))}
</div>
</div>
<span
style={{
background: brand.colors.background,
inset: 0,
opacity: open * 0.55,
position: "absolute",
}}
/>
<div
style={{
background: brand.colors.background,
borderLeft: side === "right" ? `${px(1)}px solid #26262C` : undefined,
borderRight: side === "left" ? `${px(1)}px solid #26262C` : undefined,
bottom: 0,
boxShadow: `0 0 ${px(80)}px ${brand.colors.shadow}`,
display: "flex",
flexDirection: "column",
padding: px(38),
position: "absolute",
right: side === "right" ? 0 : undefined,
left: side === "left" ? 0 : undefined,
top: 0,
transform: `translateX(${shift}%)`,
width: px(520),
}}
>
<div style={{color: brand.colors.foreground, fontSize: px(30), fontWeight: 500}}>{title}</div>
<div style={{color: brand.colors.muted, fontSize: px(20), marginTop: px(10)}}>{description}</div>
<div style={{display: "grid", gap: px(20), marginTop: px(34)}}>
{fields.map((field, index) => (
<div
key={field.label}
style={{
// Rows arrive just behind the panel, so the panel reads as
// the thing moving and the contents as its cargo.
opacity: interpolate(frame, [openAt + 12 + index * 4, openAt + 26 + index * 4], [0, 1], {
easing: Easing.standard,
}),
}}
>
<div style={{color: brand.colors.muted, fontSize: px(18)}}>{field.label}</div>
<div
style={{
border: `${px(1)}px solid ${brand.colors.border}`,
borderRadius: px(9),
color: brand.colors.foreground,
fontSize: px(22),
marginTop: px(7),
padding: `${px(13)}px ${px(16)}px`,
}}
>
{field.value}
</div>
</div>
))}
</div>
<div
style={{
background: "#FAFAFA",
borderRadius: px(10),
color: "#09090B",
fontSize: px(22),
fontWeight: 500,
marginTop: "auto",
padding: `${px(16)}px`,
textAlign: "center",
}}
>
{action}
</div>
</div>
</div>
</Fill>
);
};
videos/components/sheet-panel/sheet-panel.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {SheetPanel} from "./sheet-panel";
export default defineComponentPreview({
title: "Sheet",
category: "Interface/Chrome",
description: "A side panel arriving while the page behind it gives way.",
component: SheetPanel,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
title: {type: "text", defaultValue: "Export settings", maxLength: 32},
description: {type: "text", defaultValue: "Applies to this video only.", maxLength: 48},
action: {type: "text", defaultValue: "Export video", maxLength: 24},
side: {type: "select", defaultValue: "right", options: ["right", "left"]},
},
examples: [
{name: "From the right", props: {}},
{name: "From the left", props: {side: "left"}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {SheetPanel} from "../components/sheet-panel/sheet-panel";
<Video>
<Scene id="sheet-panel" duration="5s">
<SheetPanel
title="Export settings"
description="Applies to this video only."
action="Export video"
side="right"
/>
</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
- panel shown open, no slide
- Requires
- sans font
- Content limits
- title ≤ 32, description ≤ 48, action ≤ 24
Props
| Prop | Type | Default | Required |
|---|---|---|---|
titleThe heading inside the panel. | string | "Export settings" | — |
descriptionThe line under it. | string | "Applies to this video only." | — |
fieldsLabelled rows the panel holds. | Array<{label: string; value: string}> | — | — |
actionThe button at the foot of the panel. | string | "Export video" | — |
sideThe side it comes from. | "right" | "left" | "right" | — |
openAtFrame the panel starts opening. | number | 20 | — |
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.