Skeleton
Placeholders shimmering, then resolving row by row into content of the same height.
Customize
import {Scene, Video} from "odori";
import {SkeletonLoad} from "../components/skeleton-load/skeleton-load";
<Video>
<Scene id="skeleton-load" duration="5s">
<SkeletonLoad
title="Recent exports"
resolveAt={40}
stagger={12}
/>
</Scene>
</Video>Installation
pnpm odori add component skeleton-loadnpx odori add component skeleton-loadbunx odori add component skeleton-loadEdit the source in videos/components/skeleton-load/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/skeleton-load/skeleton-load.tsx
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from "odori";
export type SkeletonRow = {title: string; meta: string};
export type SkeletonLoadProps = {
/** The heading over the list. */
title?: string;
/** Rows that resolve, one after another. */
rows?: SkeletonRow[];
/** Frame the first row resolves. */
resolveAt?: number;
/** Frames between rows resolving. */
stagger?: number;
};
/**
* Placeholders resolving into content.
*
* The shimmer runs along the placeholders while they wait, and each row
* resolves on its own beat rather than all at once, because data arriving in
* a burst is what a mock does and data arriving in sequence is what a network
* does. The placeholder and the real row are the same height, so nothing
* shifts when the content lands.
*/
export const SkeletonLoad = ({
title = "Recent exports",
rows = [
{title: "launch.mp4", meta: "1920 × 1080 · 12.4 MB · 2m ago"},
{title: "social-announcement.mp4", meta: "1080 × 1920 · 8.1 MB · 14m ago"},
{title: "changelog.webm", meta: "1920 × 1080 · 3.2 MB · 1h ago"},
{title: "feature-tour.mp4", meta: "1920 × 1080 · 18.9 MB · 3h ago"},
],
resolveAt = 40,
stagger = 12,
}: SkeletonLoadProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const px = (value: number) => value * scale;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", background: brand.colors.background, justifyContent: "center", padding: px(120)}}>
<div
style={{
background: brand.colors.background,
border: `${px(1)}px solid ${brand.colors.border}`,
borderRadius: px(16),
fontFamily: brand.typography.sans,
maxWidth: px(1040),
opacity: enter,
transform: `translateY(${(1 - enter) * px(14)}px)`,
width: "100%",
}}
>
<div
style={{
borderBottom: `${px(1)}px solid ${brand.colors.border}`,
color: brand.colors.foreground,
fontSize: px(26),
fontWeight: 500,
padding: `${px(24)}px ${px(30)}px`,
}}
>
{title}
</div>
{rows.map((row, index) => {
const at = resolveAt + index * stagger;
const resolved = interpolate(frame, [at, at + 12], [0, 1], {easing: Easing.standard});
// The shimmer is a band travelling along the placeholder, on a two
// second cycle so it reads as waiting rather than as blinking.
const sweep = ((frame + index * 8) % 60) / 60;
return (
<div
key={row.title}
style={{
alignItems: "center",
borderTop: index === 0 ? "none" : `${px(1)}px solid #16161A`,
display: "flex",
gap: px(20),
height: px(96),
padding: `0 ${px(30)}px`,
}}
>
<span
style={{
background:
resolved > 0.5
? "#26262C"
: `linear-gradient(90deg, #17171B ${sweep * 100 - 25}%, #26262C ${sweep * 100}%, #17171B ${sweep * 100 + 25}%)`,
borderRadius: px(9),
flex: "none",
height: px(48),
width: px(48),
}}
/>
<div style={{flex: 1, minWidth: 0, position: "relative"}}>
<div style={{opacity: 1 - resolved, position: resolved > 0.99 ? "absolute" : "static"}}>
<span
style={{
background: `linear-gradient(90deg, #17171B ${sweep * 100 - 25}%, #26262C ${sweep * 100}%, #17171B ${sweep * 100 + 25}%)`,
borderRadius: px(6),
display: "block",
height: px(22),
width: "42%",
}}
/>
<span
style={{
background: `linear-gradient(90deg, #141418 ${sweep * 100 - 25}%, #202026 ${sweep * 100}%, #141418 ${sweep * 100 + 25}%)`,
borderRadius: px(6),
display: "block",
height: px(17),
marginTop: px(10),
width: "64%",
}}
/>
</div>
<div style={{opacity: resolved, position: resolved > 0.99 ? "static" : "absolute", top: 0}}>
<div style={{color: brand.colors.foreground, fontFamily: brand.typography.mono, fontSize: px(22)}}>{row.title}</div>
<div style={{color: brand.colors.muted, fontSize: px(19), marginTop: px(7)}}>{row.meta}</div>
</div>
</div>
</div>
);
})}
</div>
</Fill>
);
};
videos/components/skeleton-load/skeleton-load.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {SkeletonLoad} from "./skeleton-load";
export default defineComponentPreview({
title: "Skeleton",
category: "Interface/Feedback",
description: "Placeholders shimmering, then resolving row by row into content of the same height.",
component: SkeletonLoad,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
title: {type: "text", defaultValue: "Recent exports", maxLength: 32},
resolveAt: {type: "number", defaultValue: 40, min: 10, max: 120, step: 5},
stagger: {type: "number", defaultValue: 12, min: 0, max: 40, step: 2},
},
examples: [
{name: "Default", props: {}},
{name: "All at once", props: {stagger: 0, resolveAt: 50}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {SkeletonLoad} from "../components/skeleton-load/skeleton-load";
<Video>
<Scene id="skeleton-load" duration="5s">
<SkeletonLoad
title="Recent exports"
resolveAt={40}
stagger={12}
/>
</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
- content shown resolved, no shimmer
- Requires
- sans font
- Content limits
- title ≤ 32
Props
| Prop | Type | Default | Required |
|---|---|---|---|
titleThe heading over the list. | string | "Recent exports" | — |
rowsRows that resolve, one after another. | SkeletonRow[] | — | — |
resolveAtFrame the first row resolves. | number | 40 | — |
staggerFrames between rows resolving. | number | 12 | — |
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.