Callout
Dims the frame around a target rectangle, rings it, and points a label bubble at it from the side with the most room.
Callout, card
Installation
npx shadcn add https://www.reelcn.dev/r/callout.jsonFiles land in src/reelcn/ with the items it builds on. Dependencies: remotion.
Usage
<Sequence from={30} durationInFrames={90}>
<Callout target={{ x: 65, y: 71, width: 23, height: 8 }} label="Ship it from here" />
</Sequence>Props
PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0.target*CalloutRect | { anchors: Record<string, AnchorRect>; id: string; }Area to spotlight, in % of the canvas (0-100):
x/y is the top-left corner, width/height the size.
Percentages keep the spotlight on the same UI when the canvas changes format. A kit anchor
(`{ anchors, id }`) resolves to the exact same rect shape instead of a literal one.labelstring
side"left" | "right" | "auto" | "top" | "bottom"Where the label sits.
auto tries below, then above, then the roomier side, staying inside the safe zone. Default "auto".paddingnumberSpace added around the target, in design units. Default
12.radiusnumberCorner radius in design units. Defaults to the theme radius.
dimnumberHow dark the surroundings get, 0-1. Default
0.72.dimColorstringDim color. Defaults to the theme background.
accentColorstringRing, glow and bubble color. Defaults to the theme accent.
labelColorstringLabel text color. Defaults to the theme accent foreground.
styleCSSProperties
classNamestring
Built from
Use
- Highlighting a button, field or panel in product demos and walkthroughs
- Explaining one region of a screenshot or screen recording
Avoid
- A quick pointer that leaves the rest of the frame undimmed. Use
arrow
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/callout.tsx
/**
* @title Callout
* @category overlays
* @description Dims the frame around a target rectangle, rings it, and points a label bubble at it from the side with the most room.
* @duration 26
* @use Highlighting a button, field or panel in product demos and walkthroughs
* @use Explaining one region of a screenshot or screen recording
* @avoid A quick pointer that leaves the rest of the frame undimmed. Use `arrow`
* @tags spotlight, highlight, tooltip, focus, product, tutorial
* @example
* <Sequence from={30} durationInFrames={90}>
* <Callout target={{ x: 65, y: 71, width: 23, height: 8 }} label="Ship it from here" />
* </Sequence>
*/
import type React from "react";
import { AbsoluteFill } from "remotion";
import {
type AnchorRect,
alpha,
type MotionProps,
requireAnchor,
tween,
useMotion,
useTheme,
useViewport,
} from "./core";
export type CalloutRect = { x: number; y: number; width: number; height: number };
export type CalloutProps = MotionProps & {
/**
* Area to spotlight, in % of the canvas (0-100): `x`/`y` is the top-left corner, `width`/`height` the size.
* Percentages keep the spotlight on the same UI when the canvas changes format. A kit anchor
* (`{ anchors, id }`) resolves to the exact same rect shape instead of a literal one.
*/
target: CalloutRect | { anchors: Record<string, AnchorRect>; id: string };
label?: string;
/** Where the label sits. `auto` tries below, then above, then the roomier side, staying inside the safe zone. */
side?: "auto" | "top" | "bottom" | "left" | "right";
/** Space added around the target, in design units. */
padding?: number;
/** Corner radius in design units. Defaults to the theme radius. */
radius?: number;
/** How dark the surroundings get, 0-1. */
dim?: number;
/** Dim color. Defaults to the theme background. */
dimColor?: string;
/** Ring, glow and bubble color. Defaults to the theme accent. */
accentColor?: string;
/** Label text color. Defaults to the theme accent foreground. */
labelColor?: string;
style?: React.CSSProperties;
className?: string;
};
export function Callout({
target,
label,
side = "auto",
padding = 12,
radius,
dim = 0.72,
dimColor,
accentColor,
labelColor,
style,
className,
...motion
}: CalloutProps) {
const theme = useTheme();
const { u, width, height, safe } = useViewport();
const m = useMotion(motion);
const accent = accentColor ?? theme.colors.accent;
// `AnchorRect` and `CalloutRect` are the same shape; a kit anchor resolves to a rect (throwing on a
// missing id, ponytail-review should-fix 4's consistent contract) before anything below touches it, so
// every line after this stays exactly what it was.
const rect: CalloutRect = "anchors" in target ? requireAnchor(target.anchors, target.id, "Callout") : target;
const pad = u(padding);
const left = (rect.x / 100) * width - pad;
const top = (rect.y / 100) * height - pad;
const w = (rect.width / 100) * width + pad * 2;
const h = (rect.height / 100) * height + pad * 2;
const cx = left + w / 2;
const cy = top + h / 2;
const shown = Math.min(Math.max(m.enter, 0), 1) * (1 - m.exit);
// The spotlight starts wide and settles onto the target.
const grow = 1 + (1 - m.enter) * 0.6;
const room = {
bottom: height - safe.bottom - (top + h),
top: top - safe.top,
left: left - safe.x,
right: width - safe.x - (left + w),
};
const placed =
side !== "auto"
? side
: room.bottom >= u(130)
? "bottom"
: room.top >= u(130)
? "top"
: room.left > room.right
? "left"
: "right";
const gap = u(26);
const labelIn = tween(m.frame, m.fps, {
from: m.delay + Math.round(m.enterFrames * 0.45),
duration: m.enterFrames,
motion: m.preset,
});
const away = (1 - labelIn) * u(18);
const nudge =
placed === "bottom"
? `0 ${away}px`
: placed === "top"
? `0 ${-away}px`
: placed === "right"
? `${away}px 0`
: `${-away}px 0`;
const pointerX = placed === "left" ? left - gap : placed === "right" ? left + w + gap : cx;
const pointerY = placed === "top" ? top - gap : placed === "bottom" ? top + h + gap : cy;
// Above or below: hug the target's side of the canvas so the pointer always lands on the bubble.
const bubble: React.CSSProperties =
placed === "top" || placed === "bottom"
? {
...(placed === "bottom" ? { top: top + h + gap } : { bottom: height - top + gap }),
...(cx < width * 0.35
? { left: Math.max(safe.x, cx - u(48)) }
: cx > width * 0.65
? { right: Math.max(safe.x, width - cx - u(48)) }
: { left: cx, translate: "-50% 0" }),
maxWidth: Math.min(u(560), width - safe.x * 2),
}
: {
...(placed === "right" ? { left: left + w + gap } : { right: width - left + gap }),
top: cy,
translate: "0 -50%",
maxWidth: Math.max(room[placed] - gap, u(200)),
};
return (
<AbsoluteFill className={className} style={{ pointerEvents: "none", fontFamily: theme.fonts.body, ...style }}>
<div
style={{
position: "absolute",
left: cx - (w * grow) / 2,
top: cy - (h * grow) / 2,
width: w * grow,
height: h * grow,
borderRadius: u(radius ?? theme.radius),
// Ring, then glow, then a spread wide enough to dim the whole canvas around the hole.
boxShadow: [
`0 0 0 ${u(4)}px ${alpha(accent, shown)}`,
`0 0 ${u(40)}px ${u(6)}px ${alpha(accent, 0.45 * shown)}`,
`0 0 0 ${Math.max(width, height) * 2}px ${alpha(dimColor ?? theme.colors.background, dim * shown)}`,
].join(", "),
}}
/>
{label && (
<div
style={{
position: "absolute",
inset: 0,
opacity: Math.min(Math.max(labelIn, 0), 1) * (1 - m.exit),
translate: nudge,
}}
>
<div
style={{
position: "absolute",
width: "max-content",
...bubble,
padding: `${u(16)}px ${u(26)}px`,
borderRadius: u(Math.max(theme.radius * 0.6, 8)),
background: accent,
color: labelColor ?? theme.colors.accentForeground,
fontFamily: theme.fonts.heading,
fontSize: u(30),
fontWeight: theme.headingWeight,
lineHeight: 1.25,
boxShadow: `0 ${u(14)}px ${u(40)}px ${alpha("#000000", 0.3)}`,
}}
>
{label}
</div>
<div
style={{
position: "absolute",
left: pointerX - u(10),
top: pointerY - u(10),
width: u(20),
height: u(20),
borderRadius: u(3),
background: accent,
rotate: "45deg",
}}
/>
</div>
)}
</AbsoluteFill>
);
}