Diff Suggestion Highlight
Changed lines wash in their green and red tints one after another, then the accept bar rises beneath them.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Diff Suggestion Highlight
*
* A proposed edit arriving in place: the changed lines wash in their
* added and removed tints one after another, then the accept bar rises
* under them.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The block is mixed from the inherited text color, so it reads correctly
* on a light page and on a dark one; only the add/remove tints are literal.
* Works with zero props; tune via `variant`, `lines`, `filename`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type DiffLine = {
kind: "context" | "added" | "removed";
text: string;
};
export type DiffSuggestionHighlightProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The proposed edit, in file order. */
lines?: DiffLine[];
/** Shown in the header, above the edit. */
filename?: string;
/** Fires when the edit is applied. */
onApply?: () => void;
/** Fires when the edit is waved off. */
onDismiss?: () => void;
};
type VariantConfig = {
/** Beat before the first tint washes in. */
leadIn: number;
/** Gap between one changed line lighting up and the next. */
stagger: number;
/** How long a single tint takes to arrive. */
wash: number;
/** px the accept bar travels up. */
barRiseY: number;
barSpring: { type: "spring"; stiffness: number; damping: number };
};
// The tints are read, not watched: they wash in fast enough that the eye
// treats them as state, and the bar arrives only once the reader can see
// what they would be accepting. Damping ratios (ζ = damping / 2√stiffness)
// stay at or above 0.86 — a bar of destructive buttons must not bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.02 — everything lands flat. For review queues where a dozen of
// these scroll past in a session.
subtle: {
leadIn: 0.08,
stagger: 0.045,
wash: 0.2,
barRiseY: 6,
barSpring: { type: "spring", stiffness: 540, damping: 47 },
},
// ζ ≈ 0.93 — one clean settle under the diff. The all-purpose setting.
default: {
leadIn: 0.12,
stagger: 0.07,
wash: 0.26,
barRiseY: 12,
barSpring: { type: "spring", stiffness: 420, damping: 38 },
},
// ζ ≈ 0.87 — a slower cascade and more travel, for a single hero edit.
playful: {
leadIn: 0.16,
stagger: 0.1,
wash: 0.32,
barRiseY: 18,
barSpring: { type: "spring", stiffness: 360, damping: 33 },
},
};
const SAMPLE_LINES: DiffLine[] = [
{ kind: "context", text: "export const client = {" },
{ kind: "context", text: " timeout: 3000," },
{ kind: "removed", text: " retries: 1," },
{ kind: "added", text: " retries: 3," },
{ kind: "added", text: ' backoff: "exponential",' },
{ kind: "context", text: "};" },
];
// Add and remove colours are semantic, so they stay literal — and they are
// used at low alpha, which keeps them legible over a light page and a dark
// one alike.
const ADDED = "#34D399";
const REMOVED = "#E5484D";
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` gives a code surface and a border that are correctly
* toned in either theme. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
export default function DiffSuggestionHighlight({
variant = "default",
lines = SAMPLE_LINES,
filename = "lib/api-client.ts",
onApply,
onDismiss,
}: DiffSuggestionHighlightProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const changed = lines.filter((line) => line.kind !== "context");
const additions = changed.filter((line) => line.kind === "added").length;
const removals = changed.length - additions;
// Each changed line waits for the ones above it, so the eye travels down
// the edit in reading order rather than meeting a wall of colour. Context
// lines don't animate, so they don't consume a beat.
const delays = lines.reduce<number[]>((acc, line, index) => {
const previous = index === 0 ? -1 : acc[index - 1];
acc.push(line.kind === "context" ? previous : previous + 1);
return acc;
}, []);
const barDelay = cfg.leadIn + Math.max(0, changed.length - 1) * cfg.stagger + 0.14;
return (
<div
style={{
width: 304,
display: "flex",
flexDirection: "column",
borderRadius: 13,
background: tone(5),
border: `1px solid ${tone(12)}`,
overflow: "hidden",
fontSize: 12.5,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 12px",
borderBottom: `1px solid ${tone(10)}`,
}}
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M9.2 1.8H4.4A1.6 1.6 0 0 0 2.8 3.4v9.2a1.6 1.6 0 0 0 1.6 1.6h7.2a1.6 1.6 0 0 0 1.6-1.6V5.6z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
opacity="0.55"
/>
<path
d="M9.2 1.8v3.8h3.9"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
opacity="0.55"
/>
</svg>
<span
style={{
fontFamily: MONO,
fontSize: 11.5,
opacity: 0.62,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{filename}
</span>
<span style={{ marginLeft: "auto", display: "flex", gap: 7, fontSize: 11 }}>
<span style={{ color: ADDED, fontWeight: 600 }}>+{additions}</span>
<span style={{ color: REMOVED, fontWeight: 600 }}>−{removals}</span>
</span>
</div>
<div style={{ padding: "8px 0" }}>
{lines.map((line, index) => {
const isAdded = line.kind === "added";
const isRemoved = line.kind === "removed";
const accent = isAdded ? ADDED : REMOVED;
const delay = cfg.leadIn + delays[index] * cfg.stagger;
if (line.kind === "context") {
return (
<div
key={index}
style={{
display: "flex",
gap: 8,
padding: "3px 12px 3px 10px",
fontFamily: MONO,
fontSize: 11.5,
lineHeight: 1.6,
opacity: 0.45,
whiteSpace: "pre",
}}
>
<span aria-hidden style={{ width: 8 }} />
<span>{line.text}</span>
</div>
);
}
// Background and edge carry the change; the code itself only
// gains a little contrast. Tinting the glyphs instead would make
// a reviewed diff harder to read, not easier.
return (
<motion.div
key={index}
initial={
reduceMotion
? false
: { backgroundColor: `${accent}00`, opacity: 0.55 }
}
animate={{ backgroundColor: `${accent}1F`, opacity: 1 }}
transition={
reduceMotion
? { duration: 0 }
: { duration: cfg.wash, delay, ease: "easeOut" }
}
style={{
position: "relative",
display: "flex",
gap: 8,
padding: "3px 12px 3px 10px",
fontFamily: MONO,
fontSize: 11.5,
lineHeight: 1.6,
whiteSpace: "pre",
}}
>
<motion.span
aria-hidden
initial={reduceMotion ? false : { scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={
reduceMotion
? { duration: 0 }
: { duration: cfg.wash, delay, ease: [0.32, 0.72, 0, 1] }
}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 2,
background: accent,
transformOrigin: "top center",
}}
/>
<span aria-hidden style={{ width: 8, color: accent, fontWeight: 700 }}>
{isAdded ? "+" : isRemoved ? "−" : ""}
</span>
<span style={{ opacity: 0.88 }}>{line.text}</span>
</motion.div>
);
})}
</div>
{/* The bar arrives last and from below, so "accept" is never under
the cursor before the reader has seen what changed. */}
<motion.div
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.barRiseY }}
animate={{ opacity: 1, y: 0 }}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: {
delay: barDelay,
y: cfg.barSpring,
opacity: { duration: 0.22, ease: "easeOut" },
}
}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 12px",
borderTop: `1px solid ${tone(10)}`,
background: tone(4),
}}
>
<span style={{ fontSize: 11.5, opacity: 0.55 }}>Suggested edit</span>
<span style={{ marginLeft: "auto", display: "flex", gap: 7 }}>
<button
type="button"
onClick={onDismiss}
style={{
padding: "6px 11px",
borderRadius: 8,
background: "transparent",
color: "inherit",
border: `1px solid ${tone(14)}`,
font: "inherit",
fontSize: 12,
fontWeight: 550,
lineHeight: 1,
cursor: "pointer",
opacity: 0.75,
}}
>
Dismiss
</button>
<button
type="button"
onClick={onApply}
style={{
padding: "6px 11px",
borderRadius: 8,
background: "#5B5BD6",
color: "#fff",
border: "1px solid transparent",
font: "inherit",
fontSize: 12,
fontWeight: 600,
lineHeight: 1,
cursor: "pointer",
}}
>
Apply
</button>
</span>
</motion.div>
</div>
);
}About this pattern
A proposed edit landing inside the file it belongs to. The changed lines light up in reading order — a short green or red wash with an edge stripe drawn down the gutter — so the eye walks the change from top to bottom instead of meeting a wall of colour. Only the background and the edge carry the tint; the code keeps its own contrast, because a diff is read before it is judged. The accept bar arrives last and from below, so nothing destructive is under the cursor until the reader has seen what they would be agreeing to.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Code review
Suggested edits appear tinted in the editor with an accept and reject control attached.
Related patterns
- Agent Step TimelineEach finished stage of an agent run ticks over and grows its connector toward the one after it.
- AI Thinking PulseA calm breathing dot with a soft expanding ring — the app is thinking, not stuck.
- Guardrail NoticeA declined request settles in under the prompt, an amber edge draws down it, and the way forward follows.