Toast stack
Timed notifications with entry, hold, and dismissal.
Customize
import {Scene, Video} from "odori";
import {ToastStack} from "../components/toast-stack/toast-stack";
<Video>
<Scene id="toast-stack" duration="6s">
<ToastStack
toasts={[
{
at: 10,
title: "Build started",
detail: "odori.dev · production",
tone: "neutral"
},
{
at: 45,
title: "Tests passed",
detail: "142 checks",
tone: "positive",
hold: 60
},
{
at: 90,
title: "Deployed",
detail: "Live in 12s",
tone: "positive",
hold: 70
}
]}
corner="bottom-right"
/>
</Scene>
</Video>Installation
pnpm odori add component toast-stacknpx odori add component toast-stackbunx odori add component toast-stackEdit the source in videos/components/toast-stack/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/toast-stack/toast-stack.tsx
import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type Toast = {
/** Frame it arrives on. */
at: number;
title: string;
detail?: string;
/** Frames it stays before dismissing. */
hold?: number;
tone?: "neutral" | "positive" | "negative";
};
export type ToastStackProps = {
toasts?: Toast[];
/** Which corner the stack grows from. */
corner?: "bottom-right" | "top-right" | "bottom-left" | "top-left";
};
const CORNERS = {
"bottom-right": {bottom: 0, right: 0, column: "column-reverse" as const},
"top-right": {top: 0, right: 0, column: "column" as const},
"bottom-left": {bottom: 0, left: 0, column: "column-reverse" as const},
"top-left": {top: 0, left: 0, column: "column" as const},
};
/**
* Notifications with the three phases they actually have: entry, hold, and
* dismissal.
*
* Each toast carries its own frames, so the stack is authored as a sequence of
* events rather than animated by hand. Ones that have dismissed leave the
* layout, which is what makes the ones below slide up the way a real stack
* does.
*/
export const ToastStack = ({toasts = [], corner = "bottom-right"}: ToastStackProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const place = CORNERS[corner];
const fromRight = corner.endsWith("right");
const tones = {
neutral: brand.colors.muted,
positive: brand.colors.accent,
negative: "#e5484d",
};
return (
<Fill style={{pointerEvents: "none"}}>
<div
style={{
display: "flex",
flexDirection: place.column,
gap: 14 * scale,
padding: 48 * scale,
position: "absolute",
...place,
}}
>
{toasts.map((toast, index) => {
const hold = toast.hold ?? 70;
const enter = interpolate(frame, [toast.at, toast.at + 10], [0, 1], {easing: Easing.standard});
const leave = interpolate(frame, [toast.at + hold, toast.at + hold + 10], [1, 0], {easing: Easing.standard});
const visible = Math.min(enter, leave);
if (visible <= 0.001) return null;
return (
<div
key={index}
style={{
alignItems: "flex-start",
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 16 * scale,
boxShadow: `0 ${18 * scale}px ${44 * scale}px ${brand.colors.shadow}`,
display: "flex",
gap: 14 * scale,
// Height collapses with the fade so the stack closes up
// instead of leaving a hole where a toast used to be.
maxHeight: 200 * scale * visible,
opacity: visible,
overflow: "hidden",
padding: `${20 * scale}px ${24 * scale}px`,
transform: `translateX(${(1 - visible) * (fromRight ? 40 : -40) * scale}px)`,
width: 520 * scale,
}}
>
<span
style={{
background: tones[toast.tone ?? "neutral"],
borderRadius: 999,
flex: "none",
height: 10 * scale,
marginTop: 10 * scale,
width: 10 * scale,
}}
/>
<div style={{display: "flex", flexDirection: "column", gap: 6 * scale, minWidth: 0}}>
<strong
style={{
color: brand.colors.foreground,
fontFamily: brand.typography.sans,
fontSize: 26 * scale,
fontWeight: 560,
}}
>
{toast.title}
</strong>
{toast.detail ? (
<span style={{color: brand.colors.muted, fontFamily: brand.typography.sans, fontSize: 22 * scale}}>
{toast.detail}
</span>
) : null}
</div>
</div>
);
})}
</div>
</Fill>
);
};
videos/components/toast-stack/toast-stack.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ToastStack} from "./toast-stack";
export default defineComponentPreview({
title: "Toast stack",
category: "Interface/Feedback",
description: "Timed notifications with entry, hold, and dismissal.",
component: ToastStack,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
corner: {
type: "select",
defaultValue: "bottom-right",
options: ["bottom-right", "top-right", "bottom-left", "top-left"],
},
},
examples: [
{
name: "Default",
props: {
toasts: [
{at: 10, title: "Build started", detail: "odori.dev · production", tone: "neutral"},
{at: 45, title: "Tests passed", detail: "142 checks", tone: "positive", hold: 60},
{at: 90, title: "Deployed", detail: "Live in 12s", tone: "positive", hold: 70},
],
},
},
{
name: "Failure",
props: {
toasts: [
{at: 10, title: "Build started", tone: "neutral"},
{at: 50, title: "Build failed", detail: "Type error in schema.ts", tone: "negative", hold: 90},
],
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ToastStack} from "../components/toast-stack/toast-stack";
<Video>
<Scene id="toast-stack" duration="6s">
<ToastStack
toasts={[
{
at: 10,
title: "Build started",
detail: "odori.dev · production",
tone: "neutral"
},
{
at: 45,
title: "Tests passed",
detail: "142 checks",
tone: "positive",
hold: 60
},
{
at: 90,
title: "Deployed",
detail: "Live in 12s",
tone: "positive",
hold: 70
}
]}
corner="bottom-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
- 180 frames · 6s
- Minimum duration
- 40 frames · 1.33s
- Entrance
- 10 frames
- Exit
- 10 frames
- Reduced motion
- toasts appear and disappear without sliding
- Requires
- sans font
- Content limits
- title ≤ 48, detail ≤ 72
Props
| Prop | Type | Default | Required |
|---|---|---|---|
toasts | Toast[] | [] | — |
cornerWhich corner the stack grows from. | "bottom-right" | "top-right" | "bottom-left" | "top-left" | "bottom-right" | — |
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.