Screenshot focus
A camera move from the whole interface to one detail.
Customize
import {Scene, Video} from "odori";
import {ScreenshotFocus} from "../components/screenshot-focus/screenshot-focus";
<Video>
<Scene id="screenshot-focus" duration="4s">
<ScreenshotFocus
zoom={2.2}
at={20}
durationInFrames={34}
marker={true}
caption="One deployment, 48 seconds"
/>
</Scene>
</Video>Installation
pnpm odori add component screenshot-focusnpx odori add component screenshot-focusbunx odori add component screenshot-focusEdit the source in videos/components/screenshot-focus/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/screenshot-focus/screenshot-focus.tsx
import {Easing, Fill, interpolate, placeholderImage, useBrand, useDesignScale, useFrame} from "odori";
export type ScreenshotFocusProps = {
src?: string;
/** The detail to travel to, in fractions of the image. */
focus?: {x: number; y: number};
/** How close the move ends up. */
zoom?: number;
/** Frame the move starts, and how long it takes. */
at?: number;
durationInFrames?: number;
/** A ring drawn around the detail once the move lands. */
marker?: boolean;
caption?: string;
};
/**
* A move from the whole interface to one part of it.
*
* The reason this is a component rather than two props on an image: the shot
* only works if the wide frame is held long enough to be read, the move is
* eased rather than linear, and the marker arrives *after* the move rather
* than travelling with it. Those are three decisions that are easy to get
* subtly wrong, and wrong here reads as a camera lurching.
*/
export const ScreenshotFocus = ({
src,
focus = {x: 0.5, y: 0.5},
zoom = 2.2,
at = 20,
durationInFrames = 34,
marker = true,
caption,
}: ScreenshotFocusProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const url = src ?? placeholderImage({label: "screenshot", seed: "shot"});
const progress = interpolate(frame, [at, at + durationInFrames], [0, 1], {easing: Easing.standard});
const currentZoom = 1 + (zoom - 1) * progress;
// Translate so the focal point stays under the centre as the scale grows.
const shiftX = (0.5 - focus.x) * 100 * (currentZoom - 1);
const shiftY = (0.5 - focus.y) * 100 * (currentZoom - 1);
const landed = interpolate(frame, [at + durationInFrames, at + durationInFrames + 10], [0, 1], {
easing: Easing.standard,
});
return (
<Fill style={{overflow: "hidden"}}>
<div
style={{
height: "100%",
transform: `scale(${currentZoom}) translate(${shiftX / currentZoom}%, ${shiftY / currentZoom}%)`,
transformOrigin: "center",
width: "100%",
}}
>
<img src={url} alt="" style={{height: "100%", objectFit: "cover", width: "100%"}} />
</div>
{marker ? (
<span
style={{
border: `${Math.max(1, 3 * scale)}px solid ${brand.colors.accent}`,
borderRadius: 16 * scale,
height: 180 * scale,
left: "50%",
opacity: landed * 0.9,
position: "absolute",
top: "50%",
transform: `translate(-50%, -50%) scale(${0.9 + landed * 0.1})`,
width: 300 * scale,
}}
/>
) : null}
{caption ? (
<span
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 12 * scale,
bottom: 70 * scale,
color: brand.colors.foreground,
fontFamily: brand.typography.sans,
fontSize: 28 * scale,
left: "50%",
opacity: landed,
padding: `${16 * scale}px ${26 * scale}px`,
position: "absolute",
transform: "translateX(-50%)",
}}
>
{caption}
</span>
) : null}
</Fill>
);
};
videos/components/screenshot-focus/screenshot-focus.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ScreenshotFocus} from "./screenshot-focus";
export default defineComponentPreview({
title: "Screenshot focus",
category: "Media/Footage",
description: "A camera move from the whole interface to one detail.",
component: ScreenshotFocus,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
zoom: {type: "number", defaultValue: 2.2, min: 1.2, max: 5, step: 0.1},
at: {type: "number", defaultValue: 20, min: 0, max: 60},
durationInFrames: {type: "number", defaultValue: 34, min: 10, max: 90},
marker: {type: "boolean", defaultValue: true},
caption: {type: "text", defaultValue: "One deployment, 48 seconds"},
},
examples: [
{name: "Default", props: {}},
{name: "Top left", props: {focus: {x: 0.24, y: 0.3}, zoom: 3}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ScreenshotFocus} from "../components/screenshot-focus/screenshot-focus";
<Video>
<Scene id="screenshot-focus" duration="4s">
<ScreenshotFocus
zoom={2.2}
at={20}
durationInFrames={34}
marker={true}
caption="One deployment, 48 seconds"
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Media
- Recommended duration
- 120 frames · 4s
- Minimum duration
- 45 frames · 1.5s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- the wide frame is held, with the marker in place
- Requires
- sans font
- Content limits
- caption ≤ 64
Props
| Prop | Type | Default | Required |
|---|---|---|---|
src | string | — | — |
focusThe detail to travel to, in fractions of the image. | {x: number; y: number} | {x: 0.5 | — |
zoomHow close the move ends up. | number | 2.2 | — |
atFrame the move starts, and how long it takes. | number | 20 | — |
durationInFrames | number | 34 | — |
markerA ring drawn around the detail once the move lands. | boolean | true | — |
caption | string | — | — |
Related components
All componentsB-roll windowSupporting footage framed beside the main narrative.Canvas magnifierA magnifying lens over your content.Canvas stageFrame-driven canvas drawing with export parity.CarouselA paced media sequence with stable aspect handling.CRT terminalA retro terminal with curved glass and glowing text.Glare sweepA light reflection that sweeps across your content.