before-after
Direct visual comparison with a controlled divider.
import {Scene, Video} from "odori";
import {BeforeAfter} from "../components/before-after/before-after";
<Video>
<Scene id="before-after" duration="4s">
<BeforeAfter
beforeLabel="Before"
afterLabel="After"
settleAt={0.5}
/>
</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/before-afternpx odori add @odori/before-afterbunx odori add @odori/before-afterSource is copied into videos/components/before-after/ 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/before-after/before-after.tsx
import type {ReactNode} from "react";
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type BeforeAfterProps = {
before: ReactNode;
after: ReactNode;
beforeLabel?: string;
afterLabel?: string;
/** Where the divider ends up, 0 to 1. */
settleAt?: number;
};
/**
* One frame, two states, a divider that travels across it. Both sides are
* always rendered at full size, so nothing reflows as the divider moves.
*/
export const BeforeAfter = ({before, after, beforeLabel = "Before", afterLabel = "After", settleAt = 0.5}: BeforeAfterProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const position = interpolate(frame, [12, 60], [1, settleAt], {
easing: Easing.standard,
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const label = (text: string, side: "left" | "right") => (
<span
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 999,
bottom: 44 * scale,
color: brand.colors.muted,
fontFamily: brand.typography.mono,
fontSize: 24 * scale,
left: side === "left" ? 44 * scale : undefined,
padding: `${10 * scale}px ${20 * scale}px`,
position: "absolute",
right: side === "right" ? 44 * scale : undefined,
}}
>
{text}
</span>
);
return (
<Fill>
<Fill>{after}</Fill>
<Fill style={{clipPath: `inset(0 ${(1 - position) * 100}% 0 0)`}}>{before}</Fill>
<Fill style={{pointerEvents: "none"}}>
<div
style={{
background: brand.colors.accent,
bottom: 0,
left: `${position * 100}%`,
position: "absolute",
top: 0,
width: 3 * scale,
}}
/>
{label(beforeLabel, "left")}
{label(afterLabel, "right")}
</Fill>
</Fill>
);
};
videos/components/before-after/before-after.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {BeforeAfter} from "./before-after";
export default defineComponentPreview({
title: "Before and after",
category: "Narrative",
description: "Direct visual comparison with a controlled divider.",
component: BeforeAfter,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
beforeLabel: {type: "text", defaultValue: "Before"},
afterLabel: {type: "text", defaultValue: "After"},
settleAt: {type: "number", defaultValue: 0.5, min: 0.1, max: 0.9, step: 0.05},
},
examples: [
{
name: "Timeline",
props: {
before: "Hand-numbered frames",
after: "Ordered scenes",
},
},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {BeforeAfter} from "../components/before-after/before-after";
<Video>
<Scene id="before-after" duration="4s">
<BeforeAfter
beforeLabel="Before"
afterLabel="After"
settleAt={0.5}
/>
</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
- Narrative
- Aspect ratios
- 16:9 · 1:1
- Recommended duration
- 120 frames · 4s
- Minimum duration
- 72 frames · 2.4s
- Entrance
- 60 frames
- Exit
- 0 frames
- Reduced motion
- the divider starts at its resting position
- Requires
- sans font, mono font
- Content limits
- beforeLabel ≤ 18, afterLabel ≤ 18
Props
| Prop | Type | Default | Required |
|---|---|---|---|
before | ReactNode | — | Yes |
after | ReactNode | — | Yes |
beforeLabel | string | "Before" | — |
afterLabel | string | "After" | — |
settleAtWhere the divider ends up, 0 to 1. | number | 0.5 | — |