Canvas magnifier
A magnifying lens over your content.
Customize
import {Scene, Video} from "odori";
import {CanvasMagnifier} from "../components/canvas-magnifier/canvas-magnifier";
<Video>
<Scene id="canvas-magnifier" duration="4s">
<CanvasMagnifier
size={460}
zoom={2.4}
/>
</Scene>
</Video>Installation
pnpm odori add component canvas-magnifiernpx odori add component canvas-magnifierbunx odori add component canvas-magnifierEdit the source in videos/components/canvas-magnifier/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/canvas-magnifier/canvas-magnifier.tsx
import type {ReactNode} from "react";
import {Easing, Fill, cursorAt, interpolate, useBrand, useFrame, useDesignScale, type CursorStop} from "odori";
export type CanvasMagnifierProps = {
/** What is being magnified. Rendered once, at full size, underneath. */
children?: ReactNode;
/** Where the lens sits, in composition pixels. Accepts an authored path. */
path?: CursorStop[];
/** Static position, when the lens does not move. */
x?: number;
y?: number;
/** Lens diameter in composition pixels. */
size?: number;
/** How much larger the detail appears. */
zoom?: number;
/** Frames the lens takes to open. */
entranceFrames?: number;
};
/**
* A lens that shows the content underneath it, larger.
*
* True enlargement, not a copy: the same subtree is rendered a second time
* inside a clipped circle, scaled about the lens centre, so what the lens
* shows is the real thing and cannot drift from it. A screenshot pasted into a
* circle would be a second asset to keep in step, and it would be wrong the
* first time the content changed.
*
* The cost is that children render twice per frame. That is the honest price
* of the detail being real, and it is why the lens takes a subtree rather than
* the whole scene.
*/
export const CanvasMagnifier = ({
children,
path,
x = 960,
y = 540,
size = 460,
zoom = 2.4,
entranceFrames = 14,
}: CanvasMagnifierProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const cursor = path && path.length > 0 ? cursorAt(path, frame) : null;
const centreX = cursor ? cursor.x : x;
const centreY = cursor ? cursor.y : y;
const open = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard}) * (cursor?.visible ?? 1);
const diameter = size * scale;
const left = centreX * scale - diameter / 2;
const top = centreY * scale - diameter / 2;
return (
<Fill>
{children}
<div
aria-hidden
style={{
border: `${Math.max(1, 3 * scale)}px solid ${brand.colors.border}`,
borderRadius: "50%",
boxShadow: `0 ${20 * scale}px ${50 * scale}px ${brand.colors.shadow}`,
height: diameter,
left,
opacity: open,
overflow: "hidden",
position: "absolute",
top,
transform: `scale(${0.9 + open * 0.1})`,
width: diameter,
}}
>
{/* The same subtree, scaled about the point under the lens, so the
detail is the content rather than a picture of it. */}
<div
style={{
height: "100%",
left: 0,
position: "absolute",
top: 0,
transform: `scale(${zoom}) translate(${(diameter / 2 - centreX * scale) / zoom}px, ${
(diameter / 2 - centreY * scale) / zoom
}px)`,
transformOrigin: "top left",
width: "100%",
}}
>
{children}
</div>
</div>
</Fill>
);
};
videos/components/canvas-magnifier/canvas-magnifier.preview.tsx
import {useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {CanvasMagnifier} from "./canvas-magnifier";
const Detail = () => {
const brand = useBrand();
return (
<div style={{background: brand.colors.background, inset: 0, position: "absolute"}}>
{Array.from({length: 7}, (_, row) => (
<div
key={row}
style={{
color: row === 3 ? "#f5f5f5" : "#5a5a5a",
fontFamily: "ui-monospace, monospace",
fontSize: 34,
left: 200,
position: "absolute",
top: 260 + row * 70,
}}
>
{row === 3 ? "duration: \"12s\"," : `line ${row + 1}: const value = ${row * 7};`}
</div>
))}
</div>
);
};
export default defineComponentPreview({
title: "Canvas magnifier",
category: "Media/Canvas",
description: "A magnifying lens over your content.",
component: CanvasMagnifier,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
size: {type: "number", defaultValue: 460, min: 200, max: 900},
zoom: {type: "number", defaultValue: 2.4, min: 1.2, max: 5, step: 0.1},
},
examples: [
{name: "Default", props: {children: <Detail />, x: 520, y: 470}},
{
name: "Travelling",
props: {
children: <Detail />,
path: [
{frame: 0, x: 400, y: 300},
{frame: 45, x: 520, y: 470, hold: 25},
{frame: 80, x: 700, y: 640},
],
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {CanvasMagnifier} from "../components/canvas-magnifier/canvas-magnifier";
<Video>
<Scene id="canvas-magnifier" duration="4s">
<CanvasMagnifier
size={460}
zoom={2.4}
/>
</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
- 30 frames · 1s
- Entrance
- 14 frames
- Exit
- 0 frames
- Reduced motion
- the lens is open in place from the first frame
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
childrenWhat is being magnified. Rendered once, at full size, underneath. | ReactNode | — | — |
pathWhere the lens sits, in composition pixels. Accepts an authored path. | CursorStop[] | — | — |
xStatic position, when the lens does not move. | number | 960 | — |
y | number | 540 | — |
sizeLens diameter in composition pixels. | number | 460 | — |
zoomHow much larger the detail appears. | number | 2.4 | — |
entranceFramesFrames the lens takes to open. | number | 14 | — |
Related components
All componentsB-roll windowSupporting footage framed beside the main narrative.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.Halftone printText revealed as a pattern of printed dots.