Review card
A review comment with an animated action button.
Customize
import {Scene, Video} from "odori";
import {ReviewCard} from "../components/review-card/review-card";
<Video>
<Scene id="review-card" duration="4s">
<ReviewCard
title="Ready to merge"
status="All checks passed"
action="Squash and merge"
passAt={18}
pressAt={62}
/>
</Scene>
</Video>Installation
pnpm odori add component review-cardnpx odori add component review-cardbunx odori add component review-cardEdit the source in videos/components/review-card/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/review-card/review-card.tsx
import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type Reviewer = {
/** Two initials, or a single glyph, drawn in the circle. */
initials: string;
/** Override the circle's fill. Defaults to a tint of the brand accent. */
color?: string;
};
export type ReviewCardProps = {
title?: string;
/** What the checks say once they have passed. */
status?: string;
/** Who approved, drawn as an overlapping stack. */
reviewers?: Reviewer[];
/** The button that completes the review. */
action?: string;
/** Frame the status resolves from pending to passed. */
passAt?: number;
/** Frame the action is pressed. */
pressAt?: number;
};
/**
* The card that says a change is ready, and the button that lands it.
*
* Every code product ends its story here, and the moment is carried by three
* things at once: who signed off, whether the machines agreed, and one button
* that is clearly the last step. The avatars overlap because a review is a
* group act, and the check takes the leftmost position in the stack because
* the machines are the last approver, not the first.
*
* The status resolves on a frame rather than arriving resolved: a card that
* was always green never shows the thing worth showing, which is the moment it
* turned green.
*/
export const ReviewCard = ({
title = "Ready to merge",
status = "All checks passed",
reviewers = [{initials: "MP"}, {initials: "AZ"}],
action = "Squash and merge",
passAt = 18,
pressAt,
}: ReviewCardProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const passed = interpolate(frame, [passAt, passAt + 14], [0, 1], {easing: Easing.standard});
const press =
pressAt === undefined
? 0
: interpolate(frame, [pressAt, pressAt + 4, pressAt + 12], [0, 1, 0], {easing: Easing.standard});
const circle = 64 * scale;
return (
<Fill style={{alignItems: "center", background: brand.colors.surface, justifyContent: "center"}}>
<div
style={{
background: brand.colors.background,
borderRadius: 28 * scale,
boxShadow: `0 ${28 * scale}px ${70 * scale}px color-mix(in srgb, ${brand.colors.foreground} 12%, transparent)`,
display: "grid",
gap: 26 * scale,
minWidth: 720 * scale,
padding: `${40 * scale}px ${44 * scale}px`,
}}
>
<div style={{color: brand.colors.foreground, fontSize: 40 * scale, fontWeight: 560, letterSpacing: "-0.02em"}}>
{title}
</div>
<div style={{alignItems: "center", display: "flex", gap: 22 * scale}}>
<div style={{display: "flex"}}>
{/* The machines, then the people, each tucked under the last. */}
<div
style={{
alignItems: "center",
background: "#0f8a4a",
borderRadius: "50%",
border: `${3 * scale}px solid ${brand.colors.background}`,
color: brand.colors.foreground,
display: "flex",
fontSize: 30 * scale,
height: circle,
justifyContent: "center",
opacity: passed,
transform: `scale(${0.7 + passed * 0.3})`,
width: circle,
}}
>
✓
</div>
{reviewers.map((reviewer, index) => (
<div
key={`${reviewer.initials}-${index}`}
style={{
alignItems: "center",
background: reviewer.color ?? `color-mix(in srgb, ${brand.colors.accent} ${70 - index * 18}%, ${brand.colors.surface})`,
borderRadius: "50%",
border: `${3 * scale}px solid ${brand.colors.background}`,
color: brand.colors.background,
display: "flex",
fontSize: 24 * scale,
fontWeight: 560,
height: circle,
justifyContent: "center",
marginLeft: -18 * scale,
width: circle,
}}
>
{reviewer.initials}
</div>
))}
</div>
<div style={{color: brand.colors.foreground, fontSize: 32 * scale, opacity: 0.35 + passed * 0.65}}>
{passed > 0.5 ? status : "Running checks"}
</div>
</div>
<div
style={{
alignItems: "center",
background: "#0f8a4a",
borderRadius: 18 * scale,
color: brand.colors.foreground,
display: "flex",
filter: `brightness(${1 - press * 0.12})`,
fontSize: 34 * scale,
fontWeight: 500,
height: 86 * scale,
justifyContent: "center",
opacity: 0.4 + passed * 0.6,
transform: `scale(${1 - press * 0.02})`,
}}
>
{action}
</div>
</div>
</Fill>
);
};
videos/components/review-card/review-card.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ReviewCard} from "./review-card";
export default defineComponentPreview({
title: "Review card",
category: "Interface/Surfaces",
description: "A review comment with an animated action button.",
component: ReviewCard,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
title: {type: "text", defaultValue: "Ready to merge"},
status: {type: "text", defaultValue: "All checks passed"},
action: {type: "text", defaultValue: "Squash and merge"},
passAt: {type: "number", defaultValue: 18, min: 0, max: 90},
},
examples: [
{name: "Merging", props: {pressAt: 62}},
{
name: "A larger review",
props: {reviewers: [{initials: "MP"}, {initials: "AZ"}, {initials: "KL"}], pressAt: 70},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ReviewCard} from "../components/review-card/review-card";
<Video>
<Scene id="review-card" duration="4s">
<ReviewCard
title="Ready to merge"
status="All checks passed"
action="Squash and merge"
passAt={18}
pressAt={62}
/>
</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
- 120 frames · 4s
- Minimum duration
- 48 frames · 1.6s
- Entrance
- 24 frames
- Exit
- 10 frames
- Reduced motion
- the card arrives already passed
- Requires
- sans font
- Content limits
- reviewers ≤ 4, title ≤ 34, status ≤ 30
Props
| Prop | Type | Default | Required |
|---|---|---|---|
title | string | "Ready to merge" | — |
statusWhat the checks say once they have passed. | string | "All checks passed" | — |
reviewersWho approved, drawn as an overlapping stack. | Reviewer[] | [{initials: "MP"} | — |
actionThe button that completes the review. | string | "Squash and merge" | — |
passAtFrame the status resolves from pending to passed. | number | 18 | — |
pressAtFrame the action is pressed. | 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.