Pointer trail
A scripted pointer that leaves a fading trail along its path.
Customize
import {Scene, Video} from "odori";
import {PointerTrail} from "../components/pointer-trail/pointer-trail";
<Video>
<Scene id="pointer-trail" duration="5s">
<PointerTrail
dwellFrames={12}
travelFrames={26}
trailLength={14}
/>
</Scene>
</Video>Installation
pnpm odori add component pointer-trailnpx odori add component pointer-trailbunx odori add component pointer-trailEdit the source in videos/components/pointer-trail/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/pointer-trail/pointer-trail.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useVideo} from "odori";
export type PointerTrailProps = {
/** Stops the pointer visits in order, as percentages of the frame. */
waypoints?: Array<{x: number; y: number}>;
/** Frames the pointer rests at each waypoint before leaving. */
dwellFrames?: number;
/** Frames each leg of travel takes. */
travelFrames?: number;
/** How many recent frames of motion the trail covers. */
trailLength?: number;
/** Trail colour. Defaults to the brand accent. */
color?: string;
/** The surface the pointer moves over. */
children?: ReactNode;
};
/**
* Where the pointer is on any frame, from the authored waypoints alone. The
* trail below asks this same function about earlier frames, which is what
* keeps the whole component a pure function of the clock: there is no
* position history to accumulate, so scrubbing and parallel render workers
* cannot disagree about where the pointer has been.
*/
const positionAt = (
waypoints: Array<{x: number; y: number}>,
dwellFrames: number,
travelFrames: number,
frame: number,
): {x: number; y: number} => {
if (waypoints.length === 0) return {x: 50, y: 50};
if (waypoints.length === 1) return waypoints[0];
const legFrames = dwellFrames + travelFrames;
const leg = Math.min(Math.max(0, Math.floor(frame / legFrames)), waypoints.length - 2);
const from = waypoints[leg];
const to = waypoints[leg + 1];
const progress = interpolate(frame - leg * legFrames, [dwellFrames, legFrames], [0, 1], {easing: Easing.standard});
return {x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress};
};
/**
* A pointer that shows where it has just been.
*
* A cursor alone reads as the present tense; the fading trail behind it adds
* the last half second, which is what makes a fast gesture legible at a
* glance instead of asking the viewer to have been watching. Dots taper and
* fade with age, and during a dwell they gather under the pointer and
* disappear, so a resting cursor looks at rest.
*/
export const PointerTrail = ({
waypoints = [
{x: 72, y: 74},
{x: 30, y: 34},
{x: 62, y: 30},
{x: 44, y: 62},
],
dwellFrames = 12,
travelFrames = 26,
trailLength = 14,
color,
children,
}: PointerTrailProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const {width, height} = useVideo();
const tint = color ?? brand.colors.accent;
const size = 30 * scale;
const head = positionAt(waypoints, dwellFrames, travelFrames, frame);
const trail = Array.from({length: trailLength}, (_, index) => {
const age = (index + 1) / (trailLength + 1);
const sample = positionAt(waypoints, dwellFrames, travelFrames, frame - (index + 1));
return {age, ...sample};
});
return (
<Fill>
{children}
{trail.map((dot, index) => (
<span
key={index}
aria-hidden
style={{
background: tint,
borderRadius: 999,
filter: `blur(${1.5 * scale}px)`,
height: size * 0.5 * (1 - dot.age),
left: (dot.x / 100) * width,
opacity: 0.55 * (1 - dot.age),
position: "absolute",
top: (dot.y / 100) * height,
transform: "translate(-50%, -50%)",
width: size * 0.5 * (1 - dot.age),
}}
/>
))}
<div
aria-hidden
style={{
height: size,
left: (head.x / 100) * width,
position: "absolute",
top: (head.y / 100) * height,
// The tip is the anchor, the way a real pointer's hotspot is.
transform: "translate(-2%, -2%)",
width: size,
}}
>
<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%",
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/pointer-trail/pointer-trail.preview.tsx
import {useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {PointerTrail} from "./pointer-trail";
/** 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: "Search", left: 300, top: 260},
{label: "Library", left: 1080, top: 260},
{label: "Recent exports", left: 300, top: 620},
].map((card) => (
<div
key={card.label}
style={{
border: "1px solid #1f1f1f",
borderRadius: 16,
color: brand.colors.foreground,
fontFamily: "ui-sans-serif, system-ui",
fontSize: 38,
left: card.left,
padding: "26px 38px",
position: "absolute",
top: card.top,
width: 540,
}}
>
{card.label}
</div>
))}
</div>
);
};
export default defineComponentPreview({
title: "Pointer trail",
category: "Interface/Controls",
description: "A scripted pointer that leaves a fading trail along its path.",
component: PointerTrail,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
dwellFrames: {type: "number", defaultValue: 12, min: 0, max: 60},
travelFrames: {type: "number", defaultValue: 26, min: 8, max: 90},
trailLength: {type: "number", defaultValue: 14, min: 4, max: 30},
},
examples: [
{
name: "Default",
props: {
children: <Surface />,
waypoints: [
{x: 74, y: 78},
{x: 24, y: 28},
{x: 62, y: 28},
{x: 30, y: 62},
],
},
},
{
name: "Quick sweep",
props: {
children: <Surface />,
dwellFrames: 4,
travelFrames: 16,
trailLength: 22,
waypoints: [
{x: 82, y: 72},
{x: 26, y: 30},
{x: 66, y: 30},
{x: 26, y: 64},
{x: 74, y: 60},
],
},
},
{
name: "Tinted",
props: {
color: "#7C3AED",
trailLength: 20,
waypoints: [
{x: 20, y: 70},
{x: 50, y: 30},
{x: 80, y: 70},
],
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {PointerTrail} from "../components/pointer-trail/pointer-trail";
<Video>
<Scene id="pointer-trail" duration="5s">
<PointerTrail
dwellFrames={12}
travelFrames={26}
trailLength={14}
/>
</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
- 50 frames · 1.67s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- the pointer rests at its final waypoint with no trail
- Requires
- Odori
- Content limits
- waypoints ≤ 8
Props
| Prop | Type | Default | Required |
|---|---|---|---|
waypointsStops the pointer visits in order, as percentages of the frame. | Array<{x: number; y: number}> | — | — |
dwellFramesFrames the pointer rests at each waypoint before leaving. | number | 12 | — |
travelFramesFrames each leg of travel takes. | number | 26 | — |
trailLengthHow many recent frames of motion the trail covers. | number | 14 | — |
colorTrail colour. Defaults to the brand accent. | string | — | — |
childrenThe surface the pointer moves over. | ReactNode | — | — |
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.