Swish UI
All componentsButtons and controls Free

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.

Found on