"use client";
import * as React from "react";
export interface BorderBeamButtonProps extends Omit<React.HTMLAttributes<HTMLElement>, "color"> {
/** Button label. */
children?: React.ReactNode;
/** Renders an `<a>` instead of a `<button>`. */
href?: string;
target?: React.HTMLAttributeAnchorTarget;
rel?: string;
/** Native button type (ignored for links). */
type?: "button" | "submit" | "reset";
disabled?: boolean;
/** Filled dark surface or a quiet outlined surface. */
variant?: "solid" | "ghost";
size?: "sm" | "md" | "lg";
/** Corner style. The beam follows the same radius. */
shape?: "pill" | "rounded" | "square";
/** Time for one lap around the border, in ms. */
duration?: number;
/** Length of the beam in px. */
length?: number;
/** Colour at the head of the beam. */
colorFrom?: string;
/** Colour the beam fades through towards its tail. */
colorTo?: string;
/** Run the beam all the time, or only while hovered, focused or pressed. */
trigger?: "always" | "hover";
}
const SIZES = {
sm: "h-9 gap-1.5 px-4 text-[13px]",
md: "h-11 gap-2 px-5 text-sm",
lg: "h-13 gap-2.5 px-7 text-[15px]",
} as const;
const RADII = { pill: 9999, rounded: 12, square: 4 } as const;
const VARIANTS = {
solid:
"border-zinc-950 bg-zinc-950 text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.08)] hover:bg-zinc-900 dark:border-white/10 dark:bg-[#0b0d0f] dark:hover:bg-black",
ghost:
"border-zinc-900/15 bg-white/50 text-zinc-900 hover:bg-white/80 dark:border-white/12 dark:bg-white/[0.03] dark:text-zinc-100 dark:hover:bg-white/[0.06]",
} as const;
/** Shows only the 1px ring between the padding box and the border box (classic mask "xor"). */
const RING_MASK: React.CSSProperties = {
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
mask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
maskComposite: "exclude",
};
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const noopSubscribe = () => () => {};
/** `offset-path: rect()` moves the beam at constant speed along a rounded rectangle. */
function useOffsetPathSupport() {
return React.useSyncExternalStore(
noopSubscribe,
() => typeof CSS !== "undefined" && CSS.supports("offset-path", "rect(0 auto auto 0 round 4px)"),
() => false,
);
}
/**
* A button (or link) whose 1px border carries a travelling beam of light. The beam is a small
* gradient square moved along `offset-path: rect()` with the Web Animations API and clipped to
* the border ring by a mask, so it travels at constant speed and turns every corner.
*/
export const BorderBeamButton = React.forwardRef<HTMLButtonElement | HTMLAnchorElement, BorderBeamButtonProps>(
function BorderBeamButton(
{
children = "Start building",
href,
target,
rel,
type = "button",
disabled = false,
variant = "solid",
size = "md",
shape = "pill",
duration = 3000,
length = 80,
colorFrom = "#FF7448",
colorTo = "#B6F3D1",
trigger = "always",
className = "",
style,
onPointerEnter,
onPointerLeave,
onPointerDown,
onPointerUp,
onPointerCancel,
onFocus,
onBlur,
...props
},
ref,
) {
const reduceMotion = usePrefersReducedMotion();
const supportsPath = useOffsetPathSupport();
const animated = supportsPath && !reduceMotion;
const [hovered, setHovered] = React.useState(false);
const [focused, setFocused] = React.useState(false);
const [pressed, setPressed] = React.useState(false);
const engaged = !disabled && (hovered || focused || pressed);
const frameRef = React.useRef<HTMLSpanElement>(null);
const beamRef = React.useRef<HTMLSpanElement>(null);
const glowRef = React.useRef<HTMLSpanElement>(null);
const animsRef = React.useRef<Animation[]>([]);
const stateRef = React.useRef({ visible: true, engaged: false, trigger });
const syncRef = React.useRef<() => void>(() => {});
// Create the looping animations once per duration; play state is handled by `sync`.
React.useEffect(() => {
const frame = frameRef.current;
const nodes = [beamRef.current, glowRef.current].filter((n): n is HTMLSpanElement => n !== null);
if (!animated || !frame || nodes.length === 0 || typeof frame.animate !== "function") return;
const anims = nodes.map((node) =>
node.animate([{ offsetDistance: "0%" }, { offsetDistance: "100%" }], {
duration: Math.max(400, duration),
iterations: Infinity,
easing: "linear",
}),
);
animsRef.current = anims;
let pauseTimer: ReturnType<typeof setTimeout> | undefined;
const sync = () => {
const { visible, engaged: on, trigger: mode } = stateRef.current;
const wanted = visible && !document.hidden && (mode === "always" || on);
clearTimeout(pauseTimer);
for (const a of anims) a.updatePlaybackRate(on ? 1.6 : 1);
if (wanted) {
for (const a of anims) if (a.playState !== "running") a.play();
} else if (mode === "hover" && visible && !document.hidden) {
// Let the beam keep moving while it fades out, then stop spending frames on it.
pauseTimer = setTimeout(() => anims.forEach((a) => a.pause()), 500);
} else {
for (const a of anims) a.pause();
}
};
syncRef.current = sync;
const io = new IntersectionObserver(([entry]) => {
stateRef.current.visible = entry.isIntersecting;
sync();
});
io.observe(frame);
document.addEventListener("visibilitychange", sync);
sync();
return () => {
clearTimeout(pauseTimer);
io.disconnect();
document.removeEventListener("visibilitychange", sync);
anims.forEach((a) => a.cancel());
animsRef.current = [];
syncRef.current = () => {};
};
}, [animated, duration]);
React.useEffect(() => {
stateRef.current.engaged = engaged;
stateRef.current.trigger = trigger;
syncRef.current();
}, [engaged, trigger]);
const radius = RADII[shape] ?? RADII.pill;
const beamSize = Math.max(16, length);
const haloRadius = radius === 9999 ? radius : radius + 16;
const beamStyle: React.CSSProperties = {
width: beamSize,
height: beamSize,
offsetPath: `rect(0 auto auto 0 round ${radius}px)`,
offsetAnchor: "50% 50%",
// Head (front, in the direction of travel) → second colour → transparent tail.
background: "linear-gradient(to left, var(--bb-from), var(--bb-to) 45%, transparent)",
};
const shared = {
"data-engaged": engaged ? "true" : "false",
"data-variant": variant,
className: `group/beam relative isolate inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap border font-medium tracking-tight transition-[background-color,color,border-color,scale] duration-200 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--bb-from)] active:scale-[0.98] motion-reduce:active:scale-100 ${SIZES[size] ?? SIZES.md} ${VARIANTS[variant] ?? VARIANTS.solid} ${disabled ? "pointer-events-none opacity-50" : "cursor-pointer"} ${className}`,
style: {
["--bb-from" as string]: colorFrom,
["--bb-to" as string]: colorTo,
borderRadius: radius,
...style,
} as React.CSSProperties,
onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {
onPointerEnter?.(event);
if (event.pointerType === "mouse") setHovered(true);
},
onPointerLeave: (event: React.PointerEvent<HTMLElement>) => {
onPointerLeave?.(event);
setHovered(false);
setPressed(false);
},
// Touch has no hover: light the beam while the finger is down instead.
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
onPointerDown?.(event);
if (event.pointerType !== "mouse") setPressed(true);
},
onPointerUp: (event: React.PointerEvent<HTMLElement>) => {
onPointerUp?.(event);
setPressed(false);
},
onPointerCancel: (event: React.PointerEvent<HTMLElement>) => {
onPointerCancel?.(event);
setPressed(false);
},
onFocus: (event: React.FocusEvent<HTMLElement>) => {
onFocus?.(event);
if (event.currentTarget.matches(":focus-visible")) setFocused(true);
},
onBlur: (event: React.FocusEvent<HTMLElement>) => {
onBlur?.(event);
setFocused(false);
},
};
// At rest the beam is dimmer ("always") or hidden ("hover"); engaging brightens it and adds a halo.
const restOpacity = trigger === "always" ? "opacity-80" : "opacity-0";
const content = (
<>
{/* Halo: a blurred copy of the beam, masked to a band just outside the border. */}
{animated && (
<span
aria-hidden="true"
className="pointer-events-none absolute -inset-[17px] rounded-[inherit] p-4 opacity-0 transition-opacity duration-500 group-data-[engaged=true]/beam:opacity-60"
style={{ ...RING_MASK, borderRadius: haloRadius }}
>
<span
ref={glowRef}
className="absolute left-0 top-0 blur-[6px]"
// Same path as the crisp beam (inset back to the button edge), so the halo stays in step.
style={{ ...beamStyle, width: beamSize, height: 14, offsetPath: `inset(16px round ${radius}px)` }}
/>
</span>
)}
{/* The 1px ring that carries the beam (or a static gradient with reduced motion). */}
<span
ref={frameRef}
aria-hidden="true"
className={`pointer-events-none absolute -inset-px rounded-[inherit] p-px transition-opacity duration-300 group-data-[engaged=true]/beam:opacity-100 ${restOpacity}`}
style={{
...RING_MASK,
background: animated
? undefined
: "linear-gradient(115deg, var(--bb-from), var(--bb-to) 32%, transparent 58%, transparent 74%, color-mix(in oklab, var(--bb-from) 60%, transparent))",
}}
>
{animated && <span ref={beamRef} className="absolute left-0 top-0" style={beamStyle} />}
</span>
<span className="relative inline-flex items-center gap-[inherit]">{children}</span>
</>
);
if (href !== undefined) {
return (
<a
ref={ref as React.Ref<HTMLAnchorElement>}
href={disabled ? undefined : href}
target={target}
rel={rel ?? (target === "_blank" ? "noreferrer" : undefined)}
aria-disabled={disabled || undefined}
{...shared}
{...props}
>
{content}
</a>
);
}
return (
<button ref={ref as React.Ref<HTMLButtonElement>} type={type} disabled={disabled} {...shared} {...props}>
{content}
</button>
);
},
);