Dashboard frame
Navigation, content, and status regions for a SaaS surface.
Customize
import {Scene, Video} from "odori";
import {DashboardFrame} from "../components/dashboard-frame/dashboard-frame";
<Video>
<Scene id="dashboard-frame" duration="5s">
<DashboardFrame
product="Acme"
active={1}
title="Deployments"
status="All systems normal"
/>
</Scene>
</Video>Installation
pnpm odori add component dashboard-framenpx odori add component dashboard-framebunx odori add component dashboard-frameEdit the source in videos/components/dashboard-frame/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/dashboard-frame/dashboard-frame.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type DashboardFrameProps = {
/** Product name in the sidebar head. */
product?: string;
/** Sidebar entries. The active one is the section the content belongs to. */
sections?: string[];
/** Which section is current, by index. */
active?: number;
/** Shown along the top of the content region. */
title?: string;
/** A short status, drawn as a pill beside the title. */
status?: string;
children?: ReactNode;
};
/**
* The navigation, content, and status regions every SaaS interface has, drawn
* as a real state rather than a wireframe.
*
* The rule this family lives by: a fake dashboard that no user could reach is
* worse than no dashboard. So there is one active section, one title, and one
* status — the things a real screen commits to — and the content region is a
* slot, because that is where the truthful part of the demonstration goes.
*/
export const DashboardFrame = ({
product = "Acme",
sections = ["Overview", "Deployments", "Analytics", "Settings"],
active = 1,
title = "Deployments",
status,
children,
}: DashboardFrameProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const appear = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});
return (
<Fill style={{background: brand.colors.background, flexDirection: "row", opacity: appear}}>
<aside
style={{
borderRight: `1px solid ${brand.colors.border}`,
display: "flex",
flexDirection: "column",
gap: 8 * scale,
padding: `${36 * scale}px ${24 * scale}px`,
width: 320 * scale,
}}
>
<div
style={{
color: brand.colors.foreground,
fontFamily: brand.typography.sans,
fontSize: 30 * scale,
fontWeight: 600,
letterSpacing: "-0.02em",
marginBottom: 22 * scale,
}}
>
{product}
</div>
{sections.map((section, index) => (
<div
key={section}
style={{
background: index === active ? brand.colors.surface : "transparent",
borderRadius: 12 * scale,
color: index === active ? brand.colors.foreground : brand.colors.muted,
fontFamily: brand.typography.sans,
fontSize: 25 * scale,
padding: `${14 * scale}px ${18 * scale}px`,
}}
>
{section}
</div>
))}
</aside>
<section style={{display: "flex", flex: 1, flexDirection: "column", minWidth: 0}}>
<header
style={{
alignItems: "center",
borderBottom: `1px solid ${brand.colors.border}`,
display: "flex",
gap: 18 * scale,
padding: `${34 * scale}px ${44 * scale}px`,
}}
>
<span
style={{
color: brand.colors.foreground,
fontFamily: brand.typography.sans,
fontSize: 40 * scale,
fontWeight: 600,
letterSpacing: "-0.025em",
}}
>
{title}
</span>
{status ? (
<span
style={{
border: `1px solid ${brand.colors.border}`,
borderRadius: 999,
color: brand.colors.muted,
fontFamily: brand.typography.mono,
fontSize: 20 * scale,
padding: `${7 * scale}px ${16 * scale}px`,
}}
>
{status}
</span>
) : null}
</header>
<div style={{flex: 1, minHeight: 0, padding: 44 * scale, position: "relative"}}>{children}</div>
</section>
</Fill>
);
};
videos/components/dashboard-frame/dashboard-frame.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {DashboardFrame} from "./dashboard-frame";
const Rows = () => (
<div style={{display: "flex", flexDirection: "column", gap: 16}}>
{[
["odori.dev", "Production", "12s ago"],
["docs-site", "Preview", "4m ago"],
["studio", "Production", "1h ago"],
].map(([name, environment, when]) => (
<div
key={name}
style={{
alignItems: "center",
border: "1px solid #1f1f1f",
borderRadius: 14,
color: "#e5e5e5",
display: "flex",
fontFamily: "ui-sans-serif, system-ui",
fontSize: 26,
justifyContent: "space-between",
padding: "22px 26px",
}}
>
<span>{name}</span>
<span style={{color: "#8f8f8f"}}>{environment}</span>
<span style={{color: "#8f8f8f", fontFamily: "ui-monospace, monospace"}}>{when}</span>
</div>
))}
</div>
);
export default defineComponentPreview({
title: "Dashboard frame",
category: "Interface/Surfaces",
description: "Navigation, content, and status regions for a SaaS surface.",
component: DashboardFrame,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
product: {type: "text", defaultValue: "Acme"},
title: {type: "text", defaultValue: "Deployments"},
status: {type: "text", defaultValue: "All systems normal"},
active: {type: "number", defaultValue: 1, min: 0, max: 3},
},
examples: [
{name: "Default", props: {children: <Rows />}},
{name: "Settings", props: {active: 3, title: "Settings", status: undefined, children: <Rows />}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {DashboardFrame} from "../components/dashboard-frame/dashboard-frame";
<Video>
<Scene id="dashboard-frame" duration="5s">
<DashboardFrame
product="Acme"
active={1}
title="Deployments"
status="All systems normal"
/>
</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
- 40 frames · 1.33s
- Entrance
- 16 frames
- Exit
- 0 frames
- Reduced motion
- the frame appears without fading in
- Requires
- sans font
- Content limits
- product ≤ 24, title ≤ 40, status ≤ 48
Props
| Prop | Type | Default | Required |
|---|---|---|---|
productProduct name in the sidebar head. | string | "Acme" | — |
sectionsSidebar entries. The active one is the section the content belongs to. | string[] | ["Overview" | — |
activeWhich section is current, by index. | number | 1 | — |
titleShown along the top of the content region. | string | "Deployments" | — |
statusA short status, drawn as a pill beside the title. | string | — | — |
children | ReactNode | — | — |
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.