A pill button with a small coloured dot beside its label. On hover, focus or tap the dot grows until it fills the whole button, the dark label slides away to the right and a white copy of the label with an arrow slides into the centre. Leaving reverses it. The demo places three of these in different colours under a short restaurant call to action, and plays the effect on each in turn until you touch one. On phones the buttons stack.
// Adapted from Magic UI (https://magicui.design/docs/components/interactive-hover-button)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { ArrowRight } from "lucide-react";
const buttons = [
{ label: "Book a table", dot: "bg-orange-600", ring: "focus-visible:ring-orange-600" },
{ label: "See the menu", dot: "bg-stone-900", ring: "focus-visible:ring-stone-900" },
{ label: "Gift a dinner", dot: "bg-emerald-700", ring: "focus-visible:ring-emerald-700" },
];
/**
* A pill button with a small dot. On hover or focus the dot grows until it
* fills the button, the label slides away and a white copy with an arrow
* slides in. `demo` forces the filled look so the preview can play by itself.
*/
function DotFillButton({ label, dot, ring, demo = false }: { label: string; dot: string; ring: string; demo?: boolean }) {
const [hot, setHot] = useState(false);
const on = hot || demo;
const move = "transition-all duration-300 ease-out motion-reduce:transition-none";
return (
<button
type="button"
onPointerEnter={() => setHot(true)}
onPointerLeave={() => setHot(false)}
onFocus={() => setHot(true)}
onBlur={() => setHot(false)}
className={`relative h-12 overflow-hidden rounded-full border border-stone-300 bg-white px-7 text-center text-base font-semibold text-stone-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-50 ${ring}`}
>
<span className="flex items-center justify-center gap-2.5">
<span aria-hidden="true" className="relative flex h-2 w-2">
<span className={`button-with-expanding-dot-fill-and-arrow-ping absolute inset-0 rounded-full ${dot} ${on ? "hidden" : ""}`} />
<span className={`relative h-2 w-2 rounded-full ${dot} ${move} ${on ? "scale-[100]" : "scale-100"}`} />
</span>
<span className={`inline-block ${move} ${on ? "translate-x-12 opacity-0" : "translate-x-0 opacity-100"}`}>{label}</span>
</span>
<span aria-hidden="true" className={`absolute inset-0 flex items-center justify-center gap-2 text-white ${move} ${on ? "translate-x-0 opacity-100" : "translate-x-12 opacity-0"}`}>
{label}
<ArrowRight className="h-5 w-5" />
</span>
</button>
);
}
export default function ButtonWithExpandingDotFillAndArrow() {
// Until someone touches a button, play the effect on each one in turn.
const [demo, setDemo] = useState(0);
const [touched, setTouched] = useState(false);
useEffect(() => {
if (touched) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setDemo(-1);
return;
}
let step = 1;
const tick = () => {
// Odd steps are a short rest with nothing filled.
setDemo(step % 2 === 0 ? (step / 2) % buttons.length : -1);
step += 1;
};
const timer = window.setInterval(tick, 1100);
return () => {
window.clearInterval(timer);
};
}, [touched]);
return (
<section className="bg-orange-50 px-5 py-16 sm:px-6 sm:py-24">
<style>{`
@keyframes button-with-expanding-dot-fill-and-arrow-ping {
0% { transform: scale(1); opacity: 0.7; }
70%, 100% { transform: scale(3); opacity: 0; }
}
.button-with-expanding-dot-fill-and-arrow-ping { animation: button-with-expanding-dot-fill-and-arrow-ping 2s ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.button-with-expanding-dot-fill-and-arrow-ping { animation: none; opacity: 0; }
}
`}</style>
<div className="mx-auto max-w-3xl text-center">
<p className="text-sm font-semibold uppercase tracking-widest text-orange-700">Fennel and Rye, open Wednesday to Sunday</p>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-stone-900 sm:text-5xl">Dinner is at seven. Bring someone hungry.</h2>
<p className="mx-auto mt-5 max-w-xl text-base leading-7 text-stone-600">A wood oven, a short menu that changes on Thursdays and twenty-two seats. Tables open three weeks ahead.</p>
<div
className="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row"
onPointerEnter={() => {
setTouched(true);
setDemo(-1);
}}
onFocus={() => {
setTouched(true);
setDemo(-1);
}}
>
{buttons.map((b, i) => (
<DotFillButton key={b.label} {...b} demo={demo === i} />
))}
</div>
<p className="mt-6 text-sm text-stone-600">Hover, tap or tab to a button to see it fill.</p>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/button-with-expanding-dot-fill-and-arrow.json
The dot is an 8px circle that is scaled up a hundred times, which makes it far larger than the button. The button has `overflow-hidden`, so only the part inside the pill shows and it looks like colour flooding out from the dot.
The dark label sits in the normal flow next to the dot and slides out. The white label is positioned over the button and slides in. Using two copies lets each one have its own colour and movement, and the second is hidden from screen readers.
Yes. Replace the `on` checks with `group-hover:` and `group-focus-visible:` classes and add `group` to the button. The demo uses a little state so that it can also play the effect automatically.
Yes. Change the `button` to an `a` with an `href` and keep the same classes and handlers.
No. The white label and arrow are absolutely positioned and centred, so the button keeps the size set by the resting label.
Adapted from Interactive Hover Button in Magic UI, used under its open-source licence. Licences and credits
Related: Magnetic Button That Follows the Pointer, Shimmer Button with Light Sweep in Three Sizes, Subscribe Button with Animated Confirmed State.
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.