Spotlight card
A row of cards that a soft light visits in turn, dwelling and travelling on the frame clock.
Customize
import {Scene, Video} from "odori";
import {SpotlightCard} from "../components/spotlight-card/spotlight-card";
<Video>
<Scene id="spotlight-card" duration="8s">
<SpotlightCard
dwellFrames={50}
travelFrames={14}
columns={3}
/>
</Scene>
</Video>Installation
pnpm odori add component spotlight-cardnpx odori add component spotlight-cardbunx odori add component spotlight-cardEdit the source in videos/components/spotlight-card/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/spotlight-card/spotlight-card.tsx
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from "odori";
export type SpotlightItem = {title: string; body?: string};
export type SpotlightCardProps = {
items?: SpotlightItem[];
/** Frames the light rests on each card. */
dwellFrames?: number;
/** Frames the light takes to reach the next card. */
travelFrames?: number;
/** Cards per row. Defaults to everything on one row. */
columns?: number;
};
/**
* A row of cards with attention on a schedule.
*
* On a website this is a hover effect, which means it is the viewer's job to
* run it. Video has no cursor, so the light walks the cards itself: it dwells,
* then travels, and the card underneath it lifts a few pixels while the others
* step back a shade. The dimming matters as much as the light — a spotlight
* over uniformly bright cards says nothing.
*
* The light is one gradient over the whole grid rather than a glow per card,
* so the travel between cards is a real journey across the surface instead of
* a cross-fade pretending to be one.
*/
export const SpotlightCard = ({items, dwellFrames = 50, travelFrames = 14, columns}: SpotlightCardProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const list: SpotlightItem[] =
items && items.length > 0
? items
: [
{title: "Plan", body: "Sketch the change"},
{title: "Build", body: "Land it in pieces"},
{title: "Ship", body: "Watch it hold"},
];
const cols = Math.max(1, columns ?? list.length);
const rows = Math.ceil(list.length / cols);
const cycle = dwellFrames + travelFrames;
const step = Math.floor(frame / cycle) % list.length;
const next = (step + 1) % list.length;
const moving = interpolate(frame % cycle, [dwellFrames, cycle], [0, 1], {easing: Easing.standard});
// Card centers in percent of the grid, so the light can travel between them.
const center = (index: number) => ({
x: (((index % cols) + 0.5) / cols) * 100,
y: ((Math.floor(index / cols) + 0.5) / rows) * 100,
});
const from = center(step);
const to = center(next);
const lightX = from.x + (to.x - from.x) * moving;
const lightY = from.y + (to.y - from.y) * moving;
return (
<Fill style={{alignItems: "center", justifyContent: "center"}}>
<div
style={{
display: "grid",
gap: 28 * scale,
gridTemplateColumns: `repeat(${cols}, 1fr)`,
position: "relative",
width: "84%",
}}
>
{list.map((item, index) => {
// How much of the light this card holds right now.
const lit = index === step ? 1 - moving : index === next ? moving : 0;
return (
<div
key={`${item.title}-${index}`}
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 24 * scale,
display: "grid",
gap: 14 * scale,
minHeight: 260 * scale,
opacity: 0.6 + lit * 0.4,
padding: `${40 * scale}px ${38 * scale}px`,
transform: `translateY(${-lit * 10 * scale}px)`,
}}
>
<div style={{color: brand.colors.foreground, fontSize: 40 * scale, fontWeight: 570, letterSpacing: "-0.02em"}}>
{item.title}
</div>
{item.body ? <div style={{color: brand.colors.muted, fontSize: 28 * scale}}>{item.body}</div> : null}
</div>
);
})}
{/* The one light, drawn over everything so its travel crosses the gaps. */}
<div
style={{
background: `radial-gradient(circle at ${lightX}% ${lightY}%, color-mix(in srgb, ${brand.colors.accent} 22%, transparent), transparent 40%)`,
inset: 0,
position: "absolute",
}}
/>
</div>
</Fill>
);
};
videos/components/spotlight-card/spotlight-card.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {SpotlightCard} from "./spotlight-card";
export default defineComponentPreview({
title: "Spotlight card",
category: "Interface/Surfaces",
description: "A row of cards that a soft light visits in turn, dwelling and travelling on the frame clock.",
component: SpotlightCard,
canvas: {width: 1920, height: 1080, duration: "8s"},
controls: {
dwellFrames: {type: "number", defaultValue: 50, min: 15, max: 150},
travelFrames: {type: "number", defaultValue: 14, min: 4, max: 45},
columns: {type: "number", defaultValue: 3, min: 1, max: 4},
},
examples: [
{name: "Default", props: {}},
{
name: "Four across",
props: {
items: [
{title: "Fast", body: "Cold starts under 50ms"},
{title: "Typed", body: "End to end, no casts"},
{title: "Small", body: "12kb on the wire"},
{title: "Yours", body: "MIT, no strings"},
],
dwellFrames: 35,
},
},
{
name: "Two by two",
props: {
columns: 2,
items: [
{title: "Ingest", body: "Events land in order"},
{title: "Store", body: "Columnar, compressed"},
{title: "Query", body: "SQL over everything"},
{title: "Alert", body: "Before your pager does"},
],
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {SpotlightCard} from "../components/spotlight-card/spotlight-card";
<Video>
<Scene id="spotlight-card" duration="8s">
<SpotlightCard
dwellFrames={50}
travelFrames={14}
columns={3}
/>
</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
- 240 frames · 8s
- Minimum duration
- 64 frames · 2.13s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- the light rests on the first card and nothing dims or lifts
- Requires
- sans font
- Content limits
- items ≤ 4, title ≤ 20, body ≤ 48
Props
| Prop | Type | Default | Required |
|---|---|---|---|
items | SpotlightItem[] | — | — |
dwellFramesFrames the light rests on each card. | number | 50 | — |
travelFramesFrames the light takes to reach the next card. | number | 14 | — |
columnsCards per row. Defaults to everything on one row. | number | — | — |
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.