Cursor focus
An authored pointer that moves, holds, and clicks over a surface.
Customize
import {Scene, Video} from "odori";
import {CursorFocus} from "../components/cursor-focus/cursor-focus";
<Video>
<Scene id="cursor-focus" duration="5s">
<CursorFocus
size={28}
spotlight={false}
/>
</Scene>
</Video>Installation
pnpm odori add component cursor-focusnpx odori add component cursor-focusbunx odori add component cursor-focusEdit the source in videos/components/cursor-focus/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/cursor-focus/cursor-focus.tsx
import type {ReactNode} from "react";
import {Fill, cursorAt, useBrand, useFrame, useDesignScale, type CursorStop} from "odori";
export type CursorFocusProps = {
/** The authored path. Coordinates are the composition's own pixels. */
path: CursorStop[];
/** The surface the pointer moves over. */
children?: ReactNode;
/** Pointer size in canvas pixels, before the design scale. */
size?: number;
/** Draw a soft spotlight under the pointer, dimming the rest of the frame. */
spotlight?: boolean;
};
/**
* An authored pointer over a product surface.
*
* A surface on its own reads as a screenshot. A pointer moving across it and
* clicking is what makes the same pixels read as someone using the product,
* which is why every other component in this family composes with this one.
*
* The path is source, not a recording: the same frames come out on every
* machine, and a change to the layout is a change to two numbers rather than a
* re-record.
*/
export const CursorFocus = ({path, children, size = 28, spotlight = false}: CursorFocusProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const cursor = cursorAt(path, frame);
const width = size * scale;
return (
<Fill>
{children}
{spotlight && cursor.visible > 0 ? (
<div
aria-hidden
style={{
background: `radial-gradient(circle ${420 * scale}px at ${cursor.x * scale}px ${cursor.y * scale}px, rgba(0,0,0,0) 0%, rgba(0,0,0,0.55) 100%)`,
inset: 0,
opacity: cursor.visible,
position: "absolute",
pointerEvents: "none",
}}
/>
) : null}
<div
aria-hidden
style={{
height: width,
left: cursor.x * scale,
opacity: cursor.visible,
position: "absolute",
top: cursor.y * scale,
// The tip is the anchor, the way a real pointer's hotspot is.
transform: "translate(-2%, -2%)",
width: width,
}}
>
{/* The press ring, drawn under the arrow so it reads as the surface
responding rather than as part of the pointer. */}
{cursor.pressed > 0 ? (
<span
style={{
background: "transparent",
border: `${Math.max(1, 2 * scale)}px solid ${brand.colors.accent}`,
borderRadius: 999,
height: width * 2.4,
left: "50%",
opacity: cursor.pressed * 0.8,
position: "absolute",
top: "50%",
transform: `translate(-50%, -50%) scale(${0.4 + (1 - cursor.pressed) * 0.9})`,
width: width * 2.4,
}}
/>
) : null}
<svg
viewBox="0 0 24 24"
style={{
display: "block",
filter: `drop-shadow(0 ${2 * scale}px ${6 * scale}px rgba(0,0,0,0.5))`,
height: "100%",
transform: `scale(${1 - cursor.pressed * 0.12})`,
transformOrigin: "top left",
width: "100%",
}}
>
<path
d="M5 2.5 L5 19 L9.2 15.1 L11.9 21.2 L14.8 19.9 L12.1 13.9 L18 13.6 Z"
fill={brand.colors.foreground}
stroke={brand.colors.background}
strokeWidth={1.4}
strokeLinejoin="round"
/>
</svg>
</div>
</Fill>
);
};
videos/components/cursor-focus/cursor-focus.preview.tsx
import {useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {CursorFocus} from "./cursor-focus";
/** A plain surface, so the fixture shows the pointer rather than a product. */
const Surface = () => {
const brand = useBrand();
return (
<div style={{background: brand.colors.background, inset: 0, position: "absolute"}}>
{[
{label: "Overview", top: 220},
{label: "Deployments", top: 340},
{label: "Settings", top: 460},
].map((row) => (
<div
key={row.label}
style={{
border: "1px solid #1f1f1f",
borderRadius: 16,
color: brand.colors.foreground,
fontFamily: "ui-sans-serif, system-ui",
fontSize: 40,
left: 260,
padding: "28px 40px",
position: "absolute",
top: row.top,
width: 900,
}}
>
{row.label}
</div>
))}
</div>
);
};
export default defineComponentPreview({
title: "Cursor focus",
category: "Interface/Controls",
description: "An authored pointer that moves, holds, and clicks over a surface.",
component: CursorFocus,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
size: {type: "number", defaultValue: 28, min: 16, max: 64},
spotlight: {type: "boolean", defaultValue: false},
},
examples: [
{
name: "Default",
props: {
children: <Surface />,
path: [
{frame: 0, x: 1500, y: 900},
{frame: 30, x: 640, y: 380, click: true, hold: 20},
{frame: 60, x: 700, y: 500, click: true, hold: 15},
],
},
},
{
name: "Spotlight",
props: {
children: <Surface />,
spotlight: true,
path: [
{frame: 0, x: 1600, y: 300},
{frame: 36, x: 700, y: 620, click: true, hold: 30},
],
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {CursorFocus} from "../components/cursor-focus/cursor-focus";
<Video>
<Scene id="cursor-focus" duration="5s">
<CursorFocus
size={28}
spotlight={false}
/>
</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
- 150 frames · 5s
- Minimum duration
- 40 frames · 1.33s
- Entrance
- 6 frames
- Exit
- 20 frames
- Reduced motion
- the pointer holds its first stop without travelling
- Requires
- sans font
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
pathThe authored path. Coordinates are the composition's own pixels. | CursorStop[] | — | Yes |
childrenThe surface the pointer moves over. | ReactNode | — | — |
sizePointer size in canvas pixels, before the design scale. | number | 28 | — |
spotlightDraw a soft spotlight under the pointer, dimming the rest of the frame. | boolean | false | — |
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.