end-card
The closing frame: title, supporting line, and a call to action.
import {Scene, Video} from "odori";
import {EndCard} from "../components/end-card/end-card";
<Video>
<Scene id="end-card" duration="3s">
<EndCard
title="Author. Preview. Ship."
detail="An independent React video framework."
callToAction="npm i odori"
/>
</Scene>
</Video>The same runtime that renders the export. Scrub to any frame, switch format, and edit the fixture props.
Installation
pnpm odori add @odori/end-cardnpx odori add @odori/end-cardbunx odori add @odori/end-cardSource is copied into videos/components/end-card/ and belongs to your repository. odori diff compares it with upstream later; odori update applies the change you accept. It has no registry dependencies.
Copy and paste the following code into your project.
videos/components/end-card/end-card.tsx
import {Fill, Easing, interpolate, spring, useBrand, useFrame, useDesignScale, useVideo} from "odori";
export type EndCardProps = {
title: string;
detail?: string;
callToAction?: string;
};
/** The closing lockup: the statement, and one command to run. */
export const EndCard = ({title, detail, callToAction}: EndCardProps) => {
const frame = useFrame();
const brand = useBrand();
const {fps} = useVideo();
const scale = useDesignScale();
const rise = spring({frame, fps, from: 0.96, to: 1, damping: 20});
const line = interpolate(frame, [8, 34], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", gap: 30 * scale, justifyContent: "center", padding: 110 * scale}}>
<div
style={{
fontSize: 108 * scale,
fontWeight: 560,
letterSpacing: "-0.05em",
lineHeight: 1.02,
maxWidth: 1400 * scale,
opacity: interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard}),
textAlign: "center",
transform: `scale(${rise})`,
whiteSpace: "pre-line",
}}
>
{title}
</div>
<div
style={{
background: `linear-gradient(90deg, transparent, ${brand.colors.border}, transparent)`,
height: 1,
opacity: line,
width: `${line * 46}%`,
}}
/>
{detail ? (
<div
style={{
color: brand.colors.muted,
fontSize: 34 * scale,
letterSpacing: "-0.015em",
opacity: interpolate(frame, [14, 30], [0, 1], {easing: Easing.standard}),
textAlign: "center",
}}
>
{detail}
</div>
) : null}
{callToAction ? (
<div
style={{
alignItems: "center",
background: "rgba(255,255,255,0.04)",
border: `1px solid ${brand.colors.border}`,
borderRadius: 10 * scale,
color: brand.colors.foreground,
display: "flex",
fontFamily: brand.typography.mono,
fontSize: 28 * scale,
gap: 14 * scale,
marginTop: 8 * scale,
opacity: interpolate(frame, [22, 38], [0, 1], {easing: Easing.standard}),
padding: `${16 * scale}px ${28 * scale}px`,
transform: `translateY(${interpolate(frame, [22, 38], [12 * scale, 0], {easing: Easing.standard})}px)`,
}}
>
<span style={{color: brand.colors.muted}}>{"$"}</span>
{callToAction}
</div>
) : null}
</Fill>
);
};
videos/components/end-card/end-card.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {EndCard} from "./end-card";
export default defineComponentPreview({
title: "End card",
category: "Brand",
description: "The closing frame: title, supporting line, and a call to action.",
component: EndCard,
canvas: {width: 1920, height: 1080, duration: "3s"},
controls: {
title: {type: "text", defaultValue: "Author. Preview. Ship.", maxLength: 48},
detail: {type: "text", defaultValue: "An independent React video framework."},
callToAction: {type: "text", defaultValue: "npm i odori"},
},
examples: [
{name: "Default", props: {title: "Author. Preview. Ship.", callToAction: "npm i odori"}},
{name: "Title only", props: {title: "Available today.", detail: undefined, callToAction: undefined}},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {EndCard} from "../components/end-card/end-card";
<Video>
<Scene id="end-card" duration="3s">
<EndCard
title="Author. Preview. Ship."
detail="An independent React video framework."
callToAction="npm i odori"
/>
</Scene>
</Video>Nothing else to install.
The component imports only from odori, so there are no dependencies to add and no import paths to rewrite.
Timing contract
Every registry component declares how it behaves in time.odori test fails a cut that violates these.
- Family
- Brand
- Aspect ratios
- 16:9 · 9:16 · 1:1
- Recommended duration
- 90 frames · 3s
- Minimum duration
- 45 frames · 1.5s
- Entrance
- 24 frames
- Exit
- 12 frames
- Reduced motion
- fades without scaling
- Requires
- sans font, mono font
- Content limits
- title ≤ 48, callToAction ≤ 28
Props
| Prop | Type | Default | Required |
|---|---|---|---|
title | string | — | Yes |
detail | string | — | — |
callToAction | string | — | — |