Anchor Scroll Highlight
Jumping to a section briefly washes its background so the eye lands in the right place.
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 { useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Anchor Scroll Highlight
*
* Jumping to a section moves the page, which means the reader arrives
* somewhere they did not walk to. A brief wash over the target — and an
* accent rule drawing down its edge — answers the only question that
* matters on arrival: which part of this is the part I asked for.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the page reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type AnchorScrollHighlightProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Accent used for the wash and the edge rule. */
color?: string;
/** Notified with the id of the section jumped to. */
onJump?: (id: string) => void;
};
type VariantConfig = {
/** Seconds the wash is on screen, arrival to gone. */
hold: number;
/** Peak opacity of the tint behind the section. */
peak: number;
/** Share of the run spent arriving at full tint. */
attack: number;
};
// Quality rule: this is a confirmation, not a celebration. The wash only
// ever changes opacity — no movement, no scale, nothing that would drag
// the text the reader is about to start reading. Variants change how long
// it lingers, never how loud it is at its peak.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Gone almost before it registers. For dense reference material.
subtle: { hold: 0.9, peak: 0.1, attack: 0.1 },
// Long enough to catch the eye, short enough not to be a state.
default: { hold: 1.3, peak: 0.14, attack: 0.12 },
// A slower fade out for long-form pages with distant sections.
playful: { hold: 1.8, peak: 0.18, attack: 0.14 },
};
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SECTIONS = [
{
id: "overview",
title: "Overview",
body: "Workspaces group projects, members and billing under one roof. Every account starts with one and can hold as many as the plan allows.",
},
{
id: "members",
title: "Members",
body: "Owners manage billing and can remove anyone. Editors change content. Viewers read. Roles are per workspace, never global.",
},
{
id: "billing",
title: "Billing",
body: "Plans renew monthly on the day the workspace was created. Changing plan mid-cycle is prorated to the hour.",
},
{
id: "limits",
title: "Limits",
body: "Two hundred projects, fifty members and a hundred gigabytes of storage per workspace. Ask support to lift any of them.",
},
] as const;
export default function AnchorScrollHighlight({
variant = "default",
color = "#7C7CF0",
onJump,
}: AnchorScrollHighlightProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const scrollerRef = useRef<HTMLDivElement>(null);
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
const [active, setActive] = useState<string>(SECTIONS[0].id);
// The nonce is what lets the same link be pressed twice: a new key
// remounts the wash, and a remount is the only thing that reliably
// restarts an animation that has already finished.
const [flash, setFlash] = useState<{ id: string; nonce: number } | null>(null);
const jump = (id: string) => {
const scroller = scrollerRef.current;
const target = sectionRefs.current[id];
if (scroller && target) {
scroller.scrollTo({
top: Math.max(0, target.offsetTop - 10),
// The scroll itself is the browser's, not Motion's — which is
// also how it inherits the platform's own reduced-motion
// handling. This component asks for it explicitly anyway.
behavior: reduceMotion ? "auto" : "smooth",
});
}
setActive(id);
setFlash((current) => ({ id, nonce: (current?.nonce ?? 0) + 1 }));
onJump?.(id);
};
return (
<div
style={{
display: "flex",
width: 336,
height: 262,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 14px 36px rgba(0,0,0,0.16)",
overflow: "hidden",
}}
>
<nav
aria-label="On this page"
style={{
flexShrink: 0,
width: 104,
padding: "14px 8px 14px 12px",
borderRight: `1px solid ${tone(12)}`,
}}
>
<div
style={{
fontSize: 10,
fontWeight: 650,
letterSpacing: "0.07em",
textTransform: "uppercase",
opacity: 0.42,
padding: "0 6px 8px",
}}
>
On this page
</div>
{SECTIONS.map((section) => {
const isActive = section.id === active;
return (
<button
key={section.id}
type="button"
onClick={() => jump(section.id)}
aria-current={isActive ? "location" : undefined}
style={{
display: "block",
width: "100%",
padding: "6px 7px",
marginBottom: 2,
borderRadius: 8,
border: 0,
borderLeft: `2px solid ${isActive ? color : "transparent"}`,
background: isActive ? tone(8) : "transparent",
color: "inherit",
fontFamily: "inherit",
fontSize: 12,
fontWeight: isActive ? 600 : 500,
opacity: isActive ? 1 : 0.6,
textAlign: "left",
cursor: "pointer",
}}
>
{section.title}
</button>
);
})}
</nav>
{/* The scroll container is this panel, not the window — which is
what makes the pattern usable inside a card or a split view. In
a normal page, drop the ref and let the document scroll. */}
<div
ref={scrollerRef}
style={{
position: "relative",
flex: 1,
minWidth: 0,
padding: "14px 14px 0",
overflowY: "auto",
}}
>
{SECTIONS.map((section, index) => (
<section
key={section.id}
ref={(node) => {
sectionRefs.current[section.id] = node;
}}
aria-labelledby={`${section.id}-title`}
style={{
position: "relative",
padding: "10px 10px 12px",
marginBottom: index === SECTIONS.length - 1 ? 150 : 6,
}}
>
{flash?.id === section.id && (
<motion.span
// Remounted per jump, so pressing the same link twice
// washes twice.
key={flash.nonce}
aria-hidden
initial={{ opacity: 0 }}
// Keyframes rather than two chained animations: arrive
// quickly, then leave over the rest of the run.
animate={{ opacity: [0, cfg.peak, cfg.peak, 0] }}
transition={{
duration: reduceMotion ? cfg.hold * 0.6 : cfg.hold,
times: [0, cfg.attack, cfg.attack + 0.22, 1],
ease: "easeOut",
}}
onAnimationComplete={() => setFlash(null)}
style={{
position: "absolute",
inset: 0,
borderRadius: 12,
background: color,
pointerEvents: "none",
}}
/>
)}
{flash?.id === section.id && (
<motion.span
key={`rule-${flash.nonce}`}
aria-hidden
// The rule draws down the edge under reduced motion too —
// it is 2px wide and moves no content, so it costs the
// reader nothing while telling them where they landed.
initial={{ opacity: 0, scaleY: reduceMotion ? 1 : 0.15 }}
animate={{ opacity: [0, 1, 1, 0], scaleY: 1 }}
transition={{
opacity: {
duration: reduceMotion ? cfg.hold * 0.6 : cfg.hold,
times: [0, cfg.attack, cfg.attack + 0.22, 1],
ease: "easeOut",
},
scaleY: { duration: reduceMotion ? 0 : 0.28, ease: "easeOut" },
}}
style={{
position: "absolute",
left: 0,
top: 2,
bottom: 2,
width: 2,
borderRadius: 2,
transformOrigin: "50% 0%",
background: color,
pointerEvents: "none",
}}
/>
)}
<h3
id={`${section.id}-title`}
style={{ margin: 0, fontSize: 13.5, fontWeight: 650 }}
>
{section.title}
</h3>
<p
style={{
margin: "5px 0 0",
fontSize: 12,
lineHeight: 1.65,
opacity: 0.62,
}}
>
{section.body}
</p>
</section>
))}
</div>
</div>
);
}About this pattern
An in-page jump moves the reader somewhere they did not walk to, and the usual result is a second or two spent working out which heading was the one they asked for. A short wash over the target answers that on arrival, with an accent rule drawing down its edge for the case where the tint is too quiet to catch. The wash changes opacity and nothing else — no travel, no scale, nothing that would disturb the text about to be read — and it is keyframed to arrive fast and leave slowly, so it never becomes a state you have to dismiss. Re-pressing the same link remounts it, which is the only reliable way to restart an animation that has already finished.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Code review
Opening a link to a line or comment tints that target as the page arrives.
Related patterns
- Back to Top AppearA return control lifts into the corner once the reader is deep enough for it to matter.
- Popover Anchor FlipA popover opens below its trigger, or flips above when space runs out — the arrow follows.
- Scroll Progress BarA hairline on the top edge tracks how far through a long article the reader has come.