Spotlight
Dims the frame except a region that glides between targets, as a crisp rounded rect or a soft pool of light.
Live preview needs WebGPU, which this browser doesn't offer. You're watching a recording, and the props are read-only. Open this page in a current Chrome, Edge or Safari to change them live.
Loading the live preview
Install
Copies the source into your project. It's yours from then on: edit it like any other file.
npx framefields add spotlightWrites one file (spotlight/index.ts) into your components folder. Then:
import { Spotlight } from "./components/spotlight/index.js";
Spotlight({ targets: [{ at: 0, target: button }, { at: 60, target: menu }] })Props
Plus the props every component takes: at, duration, hold,enter, exit, x, y, width, height,theme, seed and customize.
| Prop | Type | Default |
|---|---|---|
targetsrequired | Array<{ at, target, padding }> | |
Where the light goes and when; "full" lifts the dim. | ||
dim | number | 0.68 |
| How dark the rest gets, 0–1. | ||
color | string | "#000000" |
shape | "rect" | "ellipse" | "rect" |
rect: rounded, crisp. ellipse: soft-edged pool of light. | ||
radius | number | |
feather | number | |
| Soft edge of the ellipse, px. | ||
glide | number | |
| Frames each move takes. | ||
follow | number | |
| Frames to follow a moving target before the next step. Default 6 seconds. | ||
Cues
Frames other layers can key off, on the returned node: node.cues.moves[0].frame. Change the props and everything keyed to a cue moves with it.
moves
Parts
The layers customize receives by name, to restyle or animate one piece without forking the component.
dimhole
Source
What framefields add spotlight writes for spotlight/index.ts.
Show 220 lines
/** * Spotlight: dims the frame except a region that glides between targets. * The dim layer is masked by an inverted matte (the hole), keyed between * target rects; a `"full"` step lifts the dim. * * Coordinates are the frame's: the root covers the video (default * the composition `canvas`, or the given width / height) from 0, 0. */import { type KeyframeTuple, LayerAnimation, type LayoutNode,} from "framefields";import { type Animatable, bakeSamples, bindComponent, boundsOf, defineComponent, type Rect, resolveTarget, supersede, targetSchema, themeFrames, z,} from "framefields/components";
export interface SpotlightCues { [name: string]: ReadonlyArray<{ frame: number }>; /** Each move of the light, start to settle. */ moves: ReadonlyArray<{ frame: number; end: number }>;}
export const spotlight = /* @__PURE__ */ defineComponent({ name: "Spotlight", props: () => ({ /** Where the light goes and when; `"full"` lifts the dim. */ targets: z .array( z .object({ at: z.number().int().min(0), target: z.union([z.literal("full"), targetSchema()]), padding: z.number().min(0).optional(), }) .strict(), ) .min(1), /** How dark the rest gets, 0–1. */ dim: z.number().min(0).max(1).default(0.68), color: z.string().default("#000000"), /** `rect`: rounded, crisp. `ellipse`: soft-edged pool of light. */ shape: z.enum(["rect", "ellipse"]).default("rect"), radius: z.number().min(0).optional(), /** Soft edge of the ellipse, px. */ feather: z.number().min(0).optional(), /** Frames each move takes. */ glide: z.number().int().positive().optional(), /** Frames to follow a moving target before the next step. Default 6 seconds. */ follow: z.number().int().positive().optional(), }), defaults: { enter: false, exit: false }, build(ctx, props) { const { host, theme, fps } = ctx; const s = theme.scale; const W = ctx.requested.width ?? ctx.canvas.width; const H = ctx.requested.height ?? ctx.canvas.height; const glide = props.glide ?? Math.round(themeFrames(theme, "base", fps) * 1.6); const ease = theme.motion.ease.move; const feather = props.feather ?? 60 * s; const ellipse = props.shape === "ellipse"; const steps = [...props.targets].sort((a, b) => a.at - b.at);
// The hole's rect at each step (moving targets: one per frame until the next step). const keys = { x: [] as KeyframeTuple[], y: [] as KeyframeTuple[], width: [] as KeyframeTuple[], height: [] as KeyframeTuple[], }; const dimKeys: KeyframeTuple[] = [[0, 0]]; const moves: Array<{ frame: number; end: number }> = []; let lit = false; let current: Rect | null = null; const grow = (r: Rect, pad: number): Rect => { // An ellipse needs room around the rect to contain it, plus its soft edge. const k = ellipse ? Math.SQRT2 : 1; const w = r.width * k + pad * 2 + (ellipse ? feather * 2 : 0); const h = r.height * k + pad * 2 + (ellipse ? feather * 2 : 0); return { x: r.x + r.width / 2 - w / 2, y: r.y + r.height / 2 - h / 2, width: w, height: h, }; }; const put = (f: number, r: Rect, ease_?: string) => { for (const k of ["x", "y", "width", "height"] as const) keys[k].push(ease_ ? [f, r[k], ease_] : [f, r[k]]); };
steps.forEach((step, i) => { const next = steps[i + 1]?.at ?? step.at + (props.follow ?? fps * 6); if (step.target === "full") { if (lit) dimKeys.push( [step.at, props.dim], [step.at + glide, 0, "power2.inOut"], ); lit = false; return; } const pad = step.padding ?? 16 * s; const resolved = resolveTarget(step.target, { measure: host.measure, fps, }); const rectAt = (f: number) => grow( resolved.kind === "static" ? boundsOf(resolved.rects) : resolved.at(f), pad, ); const first = rectAt(step.at + glide); if (!current) { // The first light appears where it lands, as the dim comes up. put(step.at, first); } else { put(step.at, current); put(step.at + glide, first, ease); } if (!lit) dimKeys.push( [step.at, 0], [step.at + glide, props.dim, "power2.inOut"], ); lit = true; moves.push({ frame: step.at, end: step.at + glide }); current = first; if (resolved.kind === "dynamic") { // Ride the moving target until the next step. const from = step.at + glide; const samples = Array.from( { length: Math.max(1, next - from) }, (_, k) => rectAt(from + k), ); for (const k of ["x", "y", "width", "height"] as const) for (const [f, v] of bakeSamples( samples.map((r) => r[k]), from, { tolerance: 0.4 }, ).slice(1)) keys[k].push([f, v]); current = samples[samples.length - 1] ?? first; } });
const start = current ?? { x: W / 2, y: H / 2, width: 0, height: 0 }; const firstKey = (k: "x" | "y" | "width" | "height") => (keys[k][0]?.[1] as number | undefined) ?? start[k]; const radius = props.radius ?? 18 * s; const hole = host.box({ id: ctx.partId("hole"), position: "absolute", x: firstKey("x"), y: firstKey("y"), width: firstKey("width"), height: firstKey("height"), background: ellipse ? `radial-gradient(closest-side, #ffffff ${Math.max(0, 100 - (feather / Math.max(1, Math.min(firstKey("width"), firstKey("height")) / 2)) * 100).toFixed(1)}%, rgba(255,255,255,0) 100%)` : "#ffffff", ...(!ellipse && { borderRadius: radius }), }); hole.animate( LayerAnimation.create() .keys("x", track(keys.x)) .keys("y", track(keys.y)) .keys("width", track(keys.width)) .keys("height", track(keys.height)), ); const shade = host.box({ id: ctx.partId("dim"), position: "absolute", x: 0, y: 0, width: W, height: H, background: props.color, opacity: 0, }); shade.animate(LayerAnimation.keys("opacity", track(dimKeys))); host.matte(shade, hole, "alpha-inverted"); const parts: Record<string, Animatable<LayoutNode>> = { dim: shade, hole };
const lastStep = steps[steps.length - 1] as (typeof steps)[number]; return { children: [shade], width: W, height: H, duration: lastStep.at + glide, openEnded: true, origin: [0, 0], cues: { moves } as SpotlightCues, parts, }; },});
/** Retargeting keys, or a single 0 key when nothing was keyed. */const track = (keys: KeyframeTuple[]): KeyframeTuple[] => keys.length > 0 ? supersede(keys) : [[0, 0]];
/** * Dims the frame except a region that glides between targets, as a crisp * rounded rect or a soft pool of light. */export const Spotlight = /* @__PURE__ */ bindComponent(spotlight);