A pill button with a light inside it. A bright core and a wider soft halo sit behind the label, and a thin band of the same colour leaks out around the rim. With nobody near it the light drifts slowly from one end to the other; move the pointer over the button and the light slides to follow it. The demo is a restaurant call to action with one large button and three smaller ones in other colours. On phones the buttons wrap onto separate rows.
// Adapted from Cult UI (https://cult-ui.com/docs/components/glow-button)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
type Finish = {
label: string;
/** Tailwind classes for the pill itself. */
pill: string;
/** Hot centre, body and halo of the light, as CSS colours. */
core: string;
body: string;
halo: string;
};
const primary: Finish = { label: "Reserve a table", pill: "bg-stone-300 text-stone-950 border-white/40", core: "#fffff5", body: "#ffaa81", halo: "#ffda9f" };
const finishes: Finish[] = [
{ label: "See the menu", pill: "bg-stone-800 text-white border-white/15", core: "#fff7ed", body: "#fb923c", halo: "#f97316" },
{ label: "Private dining", pill: "bg-slate-800 text-white border-white/15", core: "#f0f9ff", body: "#38bdf8", halo: "#0ea5e9" },
{ label: "Gift a dinner", pill: "bg-zinc-800 text-white border-white/15", core: "#fdf4ff", body: "#e879f9", halo: "#c026d3" },
];
function GlowButton({ finish, large = false, phase = 0 }: { finish: Finish; large?: boolean; phase?: number }) {
const button = useRef<HTMLAnchorElement>(null);
const glow = useRef<HTMLSpanElement>(null);
useEffect(() => {
const el = button.current;
const light = glow.current;
if (!el || !light) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let pointer: number | null = null; // 0..1 across the button while it is hovered or focused
let x = 0.5;
let frame = 0;
let visible = true;
const place = () => {
light.style.transform = `translateX(${(x - 0.5) * el.offsetWidth}px)`;
};
const tick = (now: number) => {
// With nobody on the button the light drifts from side to side on its own.
const target = pointer ?? 0.5 + 0.34 * Math.sin(now / 1100 + phase);
x += (target - x) * (pointer === null ? 0.06 : 0.22);
place();
frame = requestAnimationFrame(tick);
};
const start = () => {
cancelAnimationFrame(frame);
if (!still && visible && !document.hidden) frame = requestAnimationFrame(tick);
};
const onMove = (e: PointerEvent) => {
const r = el.getBoundingClientRect();
pointer = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
if (still) {
x = pointer;
place();
}
};
const onLeave = () => {
pointer = null;
};
const onFocus = () => {
pointer = 0.5;
};
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
start();
});
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerleave", onLeave);
el.addEventListener("focus", onFocus);
el.addEventListener("blur", onLeave);
document.addEventListener("visibilitychange", start);
io.observe(el);
place();
start();
return () => {
cancelAnimationFrame(frame);
io.disconnect();
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerleave", onLeave);
el.removeEventListener("focus", onFocus);
el.removeEventListener("blur", onLeave);
document.removeEventListener("visibilitychange", start);
};
}, [phase]);
return (
<span className="relative inline-flex">
{/* Soft light leaking out around the rim. */}
<span aria-hidden="true" className="absolute -inset-1 rounded-full opacity-70 blur-md" style={{ background: `linear-gradient(90deg, transparent 5%, ${finish.body} 50%, transparent 95%)` }} />
<a
ref={button}
href="#"
className={`relative isolate inline-flex items-center justify-center gap-2 overflow-hidden rounded-full border font-bold uppercase tracking-tight transition-transform duration-200 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950 ${finish.pill} ${
large ? "h-14 px-10 text-sm sm:px-14" : "h-11 px-7 text-xs"
}`}
>
<span ref={glow} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 flex items-center justify-center will-change-transform">
<span className="absolute h-[103px] w-[204px] blur-[5px]" style={{ background: `radial-gradient(43% 44% at 50% 50%, ${finish.core} 29%, ${finish.halo} 48%, ${finish.body} 61%, transparent 100%)`, opacity: 0.55 }} />
<span className="absolute h-[121px] w-[121px]" style={{ background: `radial-gradient(50% 50% at 50% 50%, ${finish.core} 4%, ${finish.body} 27%, ${finish.halo} 38%, transparent 92%)`, opacity: 0.9 }} />
</span>
<span>{finish.label}</span>
<svg aria-hidden="true" viewBox="0 0 17 9" fill="none" className="h-[9px] w-[17px]">
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="m12.495 0 4.495 4.495-4.495 4.495-.99-.99 2.805-2.805H0v-1.4h14.31L11.505.99z" />
</svg>
</a>
</span>
);
}
export default function PillButtonWithPointerFollowingInnerGlow() {
return (
<section className="relative overflow-hidden bg-stone-950 px-4 py-20 sm:px-6 sm:py-24">
<div aria-hidden="true" className="absolute left-1/2 top-0 h-72 w-[42rem] max-w-full -translate-x-1/2 rounded-full bg-orange-500/10 blur-3xl" />
<div className="relative mx-auto max-w-2xl text-center">
<p className="text-sm font-semibold uppercase tracking-widest text-orange-300">Open Wednesday to Sunday</p>
<h2 className="mt-4 text-4xl font-semibold tracking-tight text-white sm:text-5xl">Dinner cooked over one long fire.</h2>
<p className="mx-auto mt-5 max-w-lg text-base leading-7 text-stone-400">Twelve seats at the counter, eight courses, and whatever the coast sent us that morning. Tables open thirty days ahead.</p>
<div className="mt-10">
<GlowButton finish={primary} large />
</div>
<div className="mt-14 border-t border-white/10 pt-8">
<p className="text-xs font-medium uppercase tracking-widest text-stone-500">The same button in three more finishes</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-6 gap-y-5">
{finishes.map((f, i) => (
<GlowButton key={f.label} finish={f} phase={(i + 1) * 1.7} />
))}
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/pill-button-with-pointer-following-inner-glow.json
A `pointermove` listener on the button works out how far across the pointer is, from 0 to 1. A small animation loop eases the light toward that position each frame and writes a `translateX` to it. Nothing re-renders in React, so it stays smooth.
A glow that only reacts to hover is invisible on phones and in a quick scroll past. When there is no pointer on the button the same loop aims at a slow sine wave instead, so the light is always alive.
Each button moves one element with a transform. The loop stops when the button is scrolled out of view or the tab is hidden.
Yes. Change the `a` to `button type="button"`, change the ref type to `HTMLButtonElement` and remove `href`.
The core is the `h-[121px] w-[121px]` element and the halo is `h-[103px] w-[204px]`. Scale both, and keep the core roughly as tall as the halo.
Adapted from Glow Button in Cult UI, used under its open-source licence. Licences and credits
Related: Shimmer Button with Light Sweep in Three Sizes, Magnetic Button That Follows the Pointer, Button with Rotating Gradient Glow Border.
More animated buttons or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.