hoverBorderGradient
A Buttons block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call hoverBorderGradient() with no arguments for a working demo, or edit the code below live.
Implementation notes
Perimeter-traveling glow implemented via a requestAnimationFrame loop writing --hbg-x/--hbg-y CSS custom properties (never a conic-gradient spin), consumed by a blurred radial-gradient layer; clockwise/counterclockwise and duration are both honored; hover intensifies the glow/darkens the content face via CSS-only nested selectors. Polymorphic as: 'button'|'div'|'a' supported by branching the returned element shape. Simplification: the domSketch's 3 layers (content / solid mask / blurred blob) are collapsed into 2 -- the content layer doubles as the mask (its own inset + solid background is what hides the blob's center) -- which is visually equivalent to the 3-layer description. Added an optional color prop beyond the upstream API, which the spec's own researchNote flags as a reasonable clean-room addition since no color-customization prop exists upstream. Verified: tsc clean, doctor 0 diagnostics, all tests pass.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Hover Border Gradient" — clean-room reimplementation.
//
// A polymorphic pill container whose rim is traced by a soft glow that
// continuously travels around the perimeter in a loop, and reads as more
// prominent on hover. Implemented purely from the block's public
// functional/visual spec — no upstream Aceternity source was viewed or
// copied.
//
// Technique: the outer wrapper has NO padding of its own; a blurred glow
// layer sits absolutely behind everything, sized to fill the wrapper exactly
// (`inset: 0`), painted with a `radial-gradient` whose center is driven by two
// CSS custom properties (`--hbg-x`/`--hbg-y`, the same "write to a custom
// property every frame" technique `magicCard.ts` uses for its own pointer-
// tracked glow). A `requestAnimationFrame` loop started in `_onMount` steps
// those custom properties continuously through the wrapper's four corner
// anchor points at a constant linear speed — never a CSS `@keyframes` spin,
// matching the spec's own "not a conic-gradient spin" note. The solid content
// layer sits in normal flow with its own small margin, which is what carries
// it inset from the glow layer's edges: since the outer wrapper has
// `overflow: hidden` and sizes itself to the content layer's margin box, only
// a thin margin-wide ring of the glow layer ever peeks out — reading as a
// highlight traveling around the border. Hover only nudges a CSS filter
// (`brightness`/`opacity`) on the two layers via nested `&:hover` selectors —
// no JS re-tracking on hover, the loop keeps running identically underneath.
//
// The spec exposes no color-customization prop upstream; this clean-room
// version adds an optional `color` (defaults to `"neutral"`, matching the
// reference's plain white-to-transparent glow) as a reasonable enhancement.
import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing } from "@domphy/theme";
export type HoverBorderGradientTag = "button" | "div" | "a";
export interface HoverBorderGradientProps {
/** Content rendered inside the solid content layer. Defaults to a demo "Aceternity UI" label with a chevron icon. */
children?: DomphyElement | DomphyElement[] | string;
/** Style hook for the outer (glow-carrying) layer. */
containerClassName?: string;
/** Style hook for the inner content layer. */
className?: string;
/** Tag to render the outer wrapper as. Defaults to `"button"`. */
as?: HoverBorderGradientTag;
/** `href`, used only when `as: "a"`. */
href?: string;
/** Seconds per full loop around the perimeter. Defaults to `1`. */
duration?: number;
/** Loop direction. Defaults to `true` (clockwise). */
clockwise?: boolean;
/** Theme color family the glow and content fill are drawn from. Defaults to `"neutral"`. */
color?: ThemeColor;
onClick?: (event: MouseEvent) => void;
disabled?: boolean;
style?: StyleObject;
}
function asContent(value: DomphyElement | DomphyElement[] | string): (string | DomphyElement)[] {
return Array.isArray(value) ? value : [value];
}
/** Small right-pointing chevron, matching `interactiveHoverButton.ts`'s inline-SVG icon pattern. Inherits its color from the content layer's own `currentColor`. */
function chevronGlyph(): DomphyElement<"span"> {
return {
span: [
{
svg: [{ polyline: null, points: "9 18 15 12 9 6" }],
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
role: "img",
ariaHidden: "true",
style: { width: "100%", height: "100%" },
} as DomphyElement<"svg">,
],
ariaHidden: "true",
style: { display: "inline-flex", flexShrink: "0", width: themeSpacing(4), height: themeSpacing(4) },
};
}
// Four corner anchor points (percent of the wrapper's own box) the glow
// travels between, in clockwise screen order (down the right edge, across
// the bottom, up the left edge, back across the top).
const ANCHOR_POINTS: [number, number][] = [
[0, 0],
[100, 0],
[100, 100],
[0, 100],
];
/**
* A polymorphic pill container (button/div/anchor) whose rim is traced by a
* soft glow continuously traveling around the perimeter — an ambient loop
* that runs from mount regardless of hover, with hover only nudging the
* glow/content brightness. Call with no arguments for a working demo button.
*/
function hoverBorderGradient(props: HoverBorderGradientProps = {}): DomphyElement {
const content = props.children ?? "Aceternity UI";
const tag = props.as ?? "button";
const duration = Math.max(0.1, props.duration ?? 1);
const clockwise = props.clockwise ?? true;
const color = props.color ?? "neutral";
const orderedAnchors = clockwise ? ANCHOR_POINTS : [...ANCHOR_POINTS].reverse();
// Decorative blurred glow, driven purely by `_onMount`-owned CSS custom
// properties — no `color` prop needed (`_doctorDisable` isn't part of
// core's strict `PartialElement` type — build through an untyped literal,
// then assert, mirroring `overlayCanvas` in confetti.ts).
const glowLayer = {
span: null,
ariaHidden: "true",
dataSlot: "hbg-glow",
_doctorDisable: "missing-color",
style: {
position: "absolute",
inset: 0,
borderRadius: "inherit",
pointerEvents: "none",
filter: "blur(2px)",
opacity: 0.7,
transition: "opacity 200ms ease, filter 200ms ease",
backgroundImage: (listener: Listener) =>
`radial-gradient(60% 60% at var(--hbg-x, 0%) var(--hbg-y, 0%), ${themeColor(listener, "shift-1", color)}, transparent 70%)`,
} as StyleObject,
} as DomphyElement<"span">;
const contentLayer: DomphyElement<"span"> = {
span: [
chevronGlyph(),
{ span: asContent(content), style: { position: "relative" } },
],
dataSlot: "hbg-content",
dataTone: "shift-15",
class: props.className,
style: {
position: "relative",
zIndex: 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
margin: themeSpacing(0.5),
fontSize: (listener: Listener) => themeSize(listener, "inherit"),
paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
borderRadius: "999px",
backgroundColor: (listener: Listener) => themeColor(listener, "inherit", color),
color: (listener: Listener) => themeColor(listener, "shift-9", color),
transition: "filter 200ms ease",
} as StyleObject,
};
const sharedStyle: StyleObject = {
position: "relative",
display: "inline-flex",
overflow: "hidden",
borderRadius: "999px",
appearance: "none",
border: "none",
padding: 0,
cursor: props.disabled ? "not-allowed" : "pointer",
opacity: props.disabled ? 0.6 : 1,
"&:hover [data-slot=hbg-glow]": { opacity: 1, filter: "blur(1px)" },
"&:hover [data-slot=hbg-content]": { filter: "brightness(0.85)" },
...(props.style ?? {}),
};
const onMount = (node: ElementNode) => {
const wrapper = node.domElement as HTMLElement | null;
if (!wrapper) return;
let animationFrame = 0;
const totalDurationMs = duration * 1000;
const segmentDurationMs = totalDurationMs / orderedAnchors.length;
const startTime = performance.now();
const tick = (now: number) => {
// Belt-and-suspenders stop condition: some hosts (e.g. a test harness
// that wipes the DOM directly instead of going through the framework's
// removal lifecycle) never fire the "Remove" hook below. Bailing here
// once the node is detached prevents the loop from leaking forever and
// eventually ticking with a stale/invalid `now` from a torn-down timer.
if (!wrapper.isConnected) return;
const elapsed = (now - startTime) % totalDurationMs;
const segmentIndex = Math.floor(elapsed / segmentDurationMs) % orderedAnchors.length;
const segmentProgress = (elapsed % segmentDurationMs) / segmentDurationMs;
const from = orderedAnchors[segmentIndex];
const to = orderedAnchors[(segmentIndex + 1) % orderedAnchors.length];
const x = from[0] + (to[0] - from[0]) * segmentProgress;
const y = from[1] + (to[1] - from[1]) * segmentProgress;
wrapper.style.setProperty("--hbg-x", `${x}%`);
wrapper.style.setProperty("--hbg-y", `${y}%`);
animationFrame = requestAnimationFrame(tick);
};
animationFrame = requestAnimationFrame(tick);
node.addHook("Remove", () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
});
};
const children = [glowLayer, contentLayer];
if (tag === "a") {
const anchorElement: DomphyElement<"a"> = {
a: children,
href: props.href ?? "#",
class: props.containerClassName,
style: sharedStyle,
_onMount: onMount,
};
if (props.onClick) anchorElement.onClick = props.onClick as (event: MouseEvent) => void;
return anchorElement;
}
if (tag === "div") {
const divElement: DomphyElement<"div"> = {
div: children,
class: props.containerClassName,
style: sharedStyle,
_onMount: onMount,
};
if (props.onClick) divElement.onClick = props.onClick;
return divElement;
}
const buttonElement: DomphyElement<"button"> = {
button: children,
type: "button",
disabled: props.disabled,
class: props.containerClassName,
style: sharedStyle,
_onMount: onMount,
};
if (props.onClick) buttonElement.onClick = props.onClick;
return buttonElement;
}
export { hoverBorderGradient };