Sweep Link
A plain text link for a stacked menu. On hover a solid bar grows out from the left edge of the label and runs a little past its end. On leave the bar doesn't shrink back the way it came. It keeps going: the left edge sweeps across to the right and the bar ends as a thin sliver before it's gone. Run the pointer down the list and each row hands the motion on to the next.
Two edges, one direction
The bar is clipped with clip-path: inset(), and its left and right insets
are separate motion values. Entering animates the right inset from 100% to 0,
so the right edge grows across. Leaving snaps the right edge to the end and
animates the left inset from 0 to 100%. Both edges only ever move left to
right, which is what makes it read as a sweep instead of a highlight
appearing and disappearing.
If the pointer leaves halfway through the entrance, the right edge jumps to the end and the left edge starts chasing it straight away. That matches the original. If it comes back mid-exit, the left edge simply returns.
Timing
The entrance takes 160ms (duration) and the exit 1.6 times as long. Both use
a strong ease-out, cubic-bezier(0.16, 1, 0.3, 1), so most of the travel
happens in the first few frames and the exit lingers as a sliver at the end.
Turn on slow motion above to watch each edge on its own.
Letters invert where covered
The bar holds a copy of the label in the contrast colour, clipped with the
bar, so letters flip exactly as the edge passes over them. The real label sits
underneath and keeps the layout. The copy is hidden from screen readers.
The bar reaches past the end of the label by reach (1.75em by default), so
a column of links of different lengths still reads as a set of rows.
Colour
By default the bar is the foreground token and the covered letters take the
background token, so it inverts cleanly in both themes. Pass color and
contrast for an accent. The violet in the demo is oklch(0.5 0.29 285) on
white.
Keyboard focus plays the same sweep as hover. With reduced motion on, the bar appears and disappears without moving.
Usage
<nav aria-label="Main">
<ul>
{["Works", "Directors", "Company"].map((label) => (
<li key={label}>
<SweepLink label={label} href={`/${label.toLowerCase()}`} />
</li>
))}
</ul>
</nav>
"use client";
import { animate, motion, useMotionTemplate, useMotionValue, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/utils";
export type SweepLinkProps = {
label?: string;
/** Renders a link when set, a button otherwise. */
href?: string;
target?: string;
rel?: string;
/** Fill of the bar. Defaults to the foreground token. */
color?: string;
/** Text colour where the bar covers the label. Defaults to the background token. */
contrast?: string;
/** How far the bar reaches past the end of the label. Any CSS length. */
reach?: string;
/** Time for the bar to wipe in, in ms. Wiping out takes 1.6× as long. */
duration?: number;
className?: string;
onClick?: React.MouseEventHandler<HTMLElement>;
};
// Both wipes start fast and settle slowly, so leaving ends on a thin sliver.
const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export function SweepLink({
label = "Works",
href,
target,
rel,
color = "var(--color-foreground)",
contrast = "var(--color-background)",
reach = "1.75em",
duration = 160,
className,
onClick,
}: SweepLinkProps) {
const reduceMotion = useReducedMotion();
const [active, setActive] = useState(false);
// The bar's left and right edges, as insets in percent of its width.
// Hidden is either edge at 100: the bar always leaves to the right.
const left = useMotionValue(0);
const right = useMotionValue(100);
const clipPath = useMotionTemplate`inset(0 ${right}% 0 ${left}%)`;
const play = (next: boolean) => {
if (next === active) return;
setActive(next);
const t = reduceMotion ? 0 : duration / 1000;
if (next) {
// In: from hidden, the right edge grows out of the left one. Caught
// mid-exit, the left edge just comes back.
if (left.get() >= 100) {
left.jump(0);
right.jump(100);
}
animate(left, 0, { duration: t, ease: EASE_OUT });
animate(right, 0, { duration: t, ease: EASE_OUT });
} else {
// Out: the right edge snaps to the end and the left edge chases it.
right.jump(0);
animate(left, 100, { duration: t * 1.6, ease: EASE_OUT });
}
};
const shared = {
"data-active": active,
onClick,
onPointerEnter: () => play(true),
onPointerLeave: (e: React.PointerEvent<HTMLElement>) =>
play(e.currentTarget.matches(":focus-visible")),
onFocus: (e: React.FocusEvent<HTMLElement>) => play(e.currentTarget.matches(":focus-visible")),
onBlur: () => play(false),
className: cn(
"relative inline-flex cursor-pointer py-0.5 whitespace-nowrap text-left leading-tight font-medium tracking-tight text-foreground outline-offset-4 outline-ring select-none focus-visible:outline-2",
className
),
style: { "--reach": reach } as React.CSSProperties,
};
const content = (
<>
<span className="relative">{label}</span>
{/* A copy of the bar with the label in the contrast colour, clipped to
the bar's edges, so letters invert exactly where they're covered. */}
<motion.span
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 w-[calc(100%+var(--reach))] py-0.5"
style={{ backgroundColor: color, color: contrast, clipPath }}
>
{label}
</motion.span>
</>
);
return href ? (
<a href={href} target={target} rel={rel} {...shared}>
{content}
</a>
) : (
<button type="button" {...shared}>
{content}
</button>
);
}