A subscribe button that changes into a confirmed state when pressed. The filled button's label slides off to the right, the button fades into an outlined one, and the word Subscribed with a tick drops in from above on a spring. Pressing it again reverses the change. The demo is a card listing three newsletters, each with its own button, and a line underneath that counts how many are on. On phones each button moves below its newsletter.
// Adapted from Magic UI (https://magicui.design/docs/components/animated-subscribe-button)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Check, ChevronRight } from "lucide-react";
const letters = [
{ id: "ledger", name: "The Quiet Ledger", by: "Maren Oyelaran", about: "Plain-English money notes for freelancers.", cadence: "Sundays", initials: "QL", tone: "bg-rose-500", on: false },
{ id: "bench", name: "Bench Notes", by: "Tobias Hale", about: "One woodworking joint explained each week.", cadence: "Wednesdays", initials: "BN", tone: "bg-amber-500", on: true },
{ id: "tide", name: "Low Tide", by: "Ines Carrow", about: "Coastal walks you can reach by train.", cadence: "Twice a month", initials: "LT", tone: "bg-teal-600", on: false },
];
/**
* A button that swaps between two looks. The old label slides out to the
* right, the new button fades in and its label drops in from above.
*/
function SubscribeButton({ subscribed, onToggle, label }: { subscribed: boolean; onToggle: () => void; label: string }) {
const reduce = useReducedMotion();
const base = "relative flex h-11 w-36 items-center justify-center overflow-hidden rounded-full text-sm font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2";
return (
<AnimatePresence mode="wait" initial={false}>
{subscribed ? (
<motion.button
key="on"
type="button"
aria-pressed="true"
aria-label={`Subscribed to ${label}. Press to unsubscribe`}
onClick={onToggle}
className={`${base} border border-slate-300 bg-white text-slate-900`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.15 }}
>
<motion.span className="flex items-center gap-1.5" initial={reduce ? false : { y: -44 }} animate={{ y: 0 }} transition={{ type: "spring", stiffness: 420, damping: 24 }}>
<Check aria-hidden="true" className="h-4 w-4 text-emerald-600" strokeWidth={3} />
Subscribed
</motion.span>
</motion.button>
) : (
<motion.button
key="off"
type="button"
aria-pressed="false"
aria-label={`Subscribe to ${label}`}
onClick={onToggle}
className={`${base} bg-rose-600 text-white shadow-md shadow-rose-600/25 transition-colors hover:bg-rose-700`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.15 }}
whileTap={reduce ? undefined : { scale: 0.96 }}
>
<motion.span className="flex items-center gap-1" initial={{ x: 0 }} exit={reduce ? undefined : { x: 50, transition: { duration: 0.1 } }}>
Subscribe
<ChevronRight aria-hidden="true" className="h-4 w-4" />
</motion.span>
</motion.button>
)}
</AnimatePresence>
);
}
export default function SubscribeButtonWithAnimatedConfirmedState() {
const [state, setState] = useState<Record<string, boolean>>(() => Object.fromEntries(letters.map((l) => [l.id, l.on])));
const count = Object.values(state).filter(Boolean).length;
// Until someone touches the list, press the first button now and then so the preview shows the change.
const [touched, setTouched] = useState(false);
useEffect(() => {
if (touched) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const flip = () => setState((s) => ({ ...s, [letters[0].id]: !s[letters[0].id] }));
const first = window.setTimeout(flip, 900);
const timer = window.setInterval(flip, 2400);
return () => {
window.clearTimeout(first);
window.clearInterval(timer);
};
}, [touched]);
return (
<section className="bg-rose-50 px-5 py-16 sm:px-6 sm:py-20">
<div className="mx-auto max-w-2xl">
<p className="text-sm font-semibold uppercase tracking-widest text-rose-700">Reading list</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl">Three letters worth opening</h2>
<p className="mt-3 text-base text-slate-600">Short, written by one person each, and easy to leave. Tap a button to try the animation.</p>
<ul onPointerEnter={() => setTouched(true)} onFocus={() => setTouched(true)} className="mt-8 divide-y divide-rose-100 overflow-hidden rounded-3xl border border-rose-100 bg-white shadow-sm">
{letters.map((l) => (
<li key={l.id} className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:p-6">
<div className="flex min-w-0 flex-1 items-center gap-4">
<span aria-hidden="true" className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl text-sm font-bold text-white ${l.tone}`}>
{l.initials}
</span>
<div className="min-w-0">
<h3 className="font-semibold text-slate-900">{l.name}</h3>
<p className="text-sm text-slate-600">{l.about}</p>
<p className="mt-1 text-xs text-slate-600">
{l.by} ยท {l.cadence}
</p>
</div>
</div>
<SubscribeButton label={l.name} subscribed={state[l.id]} onToggle={() => setState((s) => ({ ...s, [l.id]: !s[l.id] }))} />
</li>
))}
</ul>
<p className="mt-5 text-sm text-slate-600" aria-live="polite">
You are subscribed to {count} of {letters.length}. No card needed, unsubscribe with one tap.
</p>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/subscribe-button-with-animated-confirmed-state.json
The two states are separate buttons inside `AnimatePresence` with `mode="wait"`. When the state flips, the old button plays its exit (the label slides right and the button fades), then the new one mounts, fades in and drops its label in from above.
The old button leaves the page and a new one mounts, so focus can be lost in some browsers. If your users tab through long lists, give the new button `autoFocus` when the change came from the keyboard, or render one button and animate only the inner spans.
Call your request inside `onToggle`. For a quick feel, flip the state first and flip it back if the request fails, showing a short error message next to the button.
Yes. Remove `onClick` from the on-state button and add `disabled`, and change the label to something like `You are in`.
The two labels have different lengths. A fixed width stops the row from shifting as the button changes, which matters most in a list where several buttons line up.
Adapted from Animated Subscribe Button in Magic UI, used under its open-source licence. Licences and credits
Related: Hold to Confirm Button with Progress Ring, Button with Expanding Dot Fill and Arrow, Newsletter Signup with Benefit List.
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.