Border beam button
A bright teal segment of light runs around the button border in a continuous loop, with a soft glow behind it.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useRef } from "react";
import { useInView } from "@/lib/motion";
import styles from "./demo.module.css";
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const inView = useInView(root);
return (
<div ref={root} className={`${styles.root} ${inView ? styles.running : ""}`}>
<div className={styles.stack}>
<span className={styles.frame}>
<button type="button" className={styles.button}>
Upgrade to Pro
</button>
</span>
<p className={styles.note}>Unlimited listings, cancel any time</p>
</div>
</div>
);
}
demo.module.css
@property --beam-angle { syntax: "<angle>"; initial-value: 0deg; inherits: true; }
.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.stack { display: grid; justify-items: center; gap: 14px; }
.frame {
--beam-angle: 0deg;
position: relative; isolation: isolate; display: inline-grid; padding: 2px; border-radius: 999px;
background:
conic-gradient(from var(--beam-angle), transparent 0deg 220deg, var(--accent) 310deg, var(--white) 350deg, transparent 360deg),
color-mix(in srgb, var(--white) 16%, var(--ink));
animation: beam 3.2s linear infinite paused;
transition: transform .18s var(--out);
}
.frame::before {
content: ""; position: absolute; inset: -2px; z-index: -1; border-radius: inherit;
background: conic-gradient(from var(--beam-angle), transparent 0deg 270deg, var(--accent) 340deg, transparent 360deg);
filter: blur(10px); opacity: .75;
}
.running .frame { animation-play-state: running; }
.frame:active { transform: scale(.97); }
@keyframes beam { to { --beam-angle: 360deg; } }
.button {
min-height: 52px; padding: 0 32px; border: 0; border-radius: 999px;
background: var(--ink); color: var(--white); font-size: 16px; font-weight: 600; cursor: pointer;
transition: background-color .18s;
}
.button:hover { background: var(--ink-hover); }
.note { font-size: 13px; color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
.frame { animation: none; --beam-angle: 60deg; }
}
Use it for
- An upgrade or launch button that should be noticed from across the page
- Dark hero sections where one element needs a living accent
Avoid
- Next to other animated elements, since constant motion competes
- Ordinary form buttons, where looping light reads as a warning
How it works
The button sits inside a wrapper with 2px of padding that shows the wrapper background as the border. That background is a conic-gradient, transparent for most of the circle and bright for the last 140 degrees, whose start angle is a registered @property so a keyframe can animate it from 0 to 360deg. A blurred pseudo element repeats the same gradient as a glow. useInView toggles animation-play-state so the loop pauses off screen.
Accessibility
The button is a plain native button with visible text and focus outline. Reduced motion stops the loop and leaves the segment parked in one place, so the border still looks finished.