Star Toggle Toggle
Original · freebookmark/star save toggle
byWeb InnoventixReact + Tailwind
tglstartoggletoggles
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/tgl-star-toggle.jsontgl-star-toggle.tsx
"use client";
import { useId, useState, type ChangeEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
const KEYFRAMES = `
@keyframes tglstar-hint {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
@keyframes tglstar-shine {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
.tglstar-hint { animation: tglstar-hint 2.6s ease-in-out infinite; transform-origin: center; }
.tglstar-shine {
background-image: linear-gradient(110deg, transparent 30%, rgba(245,158,11,0.25) 50%, transparent 70%);
background-size: 200% 100%;
animation: tglstar-shine 5s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.tglstar-hint, .tglstar-shine { animation: none !important; }
}
`;
const BURST_PARTICLES = Array.from({ length: 7 }, (_, i) => {
const angle = (i / 7) * Math.PI * 2;
const radius = 20;
return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };
});
function StarIcon({ filled, className }: { filled: boolean; className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M12 2.6l2.95 5.98 6.6.96-4.78 4.65 1.13 6.57L12 17.9l-5.9 3.09 1.13-6.57L2.45 9.54l6.6-.96L12 2.6z" />
</svg>
);
}
function BookmarkIcon({ filled, className }: { filled: boolean; className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M6.5 3.5h11a1 1 0 0 1 1 1V21l-6.5-3.8L5.5 21V4.5a1 1 0 0 1 1-1z" />
</svg>
);
}
function useSaveToggle(initial: boolean, reduced: boolean) {
const [saved, setSaved] = useState(initial);
const [burst, setBurst] = useState(0);
const toggle = () => {
const next = !saved;
setSaved(next);
if (next && !reduced) setBurst((b) => b + 1);
};
return { saved, burst, toggle };
}
function AnimatedIcon({
saved,
burst,
reduced,
children,
}: {
saved: boolean;
burst: number;
reduced: boolean;
children: ReactNode;
}) {
if (reduced) return <span className="relative flex">{children}</span>;
return (
<motion.span
key={saved ? `on-${burst}` : "off"}
className="relative flex"
initial={{ scale: saved ? 0.5 : 1 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 520, damping: 13 }}
>
{children}
</motion.span>
);
}
function Burst({ burst, dot, ring }: { burst: number; dot: string; ring: string }) {
if (burst === 0) return null;
return (
<span aria-hidden="true" className="pointer-events-none absolute inset-0 flex items-center justify-center">
<motion.span
key={`ring-${burst}`}
className={`absolute rounded-full border ${ring}`}
initial={{ width: 8, height: 8, opacity: 0.65 }}
animate={{ width: 46, height: 46, opacity: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
/>
{BURST_PARTICLES.map((p, i) => (
<motion.span
key={`${burst}-${i}`}
className={`absolute h-1 w-1 rounded-full ${dot}`}
initial={{ opacity: 0, x: 0, y: 0, scale: 0.5 }}
animate={{ opacity: [0, 1, 0], x: p.x, y: p.y, scale: [0.5, 1, 0.3] }}
transition={{ duration: 0.55, ease: "easeOut" }}
/>
))}
</span>
);
}
function FavoriteStarCard() {
const prefersReduced = useReducedMotion();
const reduced = !!prefersReduced;
const statusId = useId();
const { saved, burst, toggle } = useSaveToggle(false, reduced);
return (
<div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900/60 sm:col-span-2">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900 dark:text-white">Add to favorites</h3>
<p className="mt-1 max-w-[22rem] text-sm text-slate-500 dark:text-slate-400">
Star “Nova Analytics” to pin it to the top of your starred workspaces.
</p>
</div>
<motion.button
type="button"
onClick={toggle}
aria-pressed={saved}
aria-label={saved ? "Remove from favorites" : "Add to favorites"}
aria-describedby={statusId}
whileTap={reduced ? undefined : { scale: 0.9 }}
className={`relative grid h-14 w-14 shrink-0 place-items-center rounded-full border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
saved
? "border-amber-400/50 bg-amber-400/10 text-amber-500 dark:text-amber-400"
: "border-slate-200 text-slate-400 hover:border-amber-300 hover:text-amber-500 dark:border-slate-700 dark:text-slate-500 dark:hover:border-amber-500/50 dark:hover:text-amber-400"
}`}
>
<span
aria-hidden="true"
className={`absolute inset-1 rounded-full bg-amber-400/25 blur-md transition-opacity duration-500 ${
saved ? "opacity-100" : "opacity-0"
}`}
/>
<AnimatedIcon saved={saved} burst={burst} reduced={reduced}>
<span className={!saved && !reduced ? "tglstar-hint flex" : "flex"}>
<StarIcon filled={saved} className="relative h-7 w-7" />
</span>
</AnimatedIcon>
<Burst burst={burst} dot="bg-amber-400" ring="border-amber-400/60" />
</motion.button>
</div>
<p
id={statusId}
aria-live="polite"
className="mt-5 text-xs font-medium text-slate-500 dark:text-slate-400"
>
{saved ? "Starred — added to your favorites." : "Not starred yet. Press Space or Enter to save."}
</p>
</div>
);
}
function SaveArticleCard() {
const prefersReduced = useReducedMotion();
const reduced = !!prefersReduced;
const { saved, burst, toggle } = useSaveToggle(false, reduced);
return (
<div className="rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900/60">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">Read it later</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Bookmark this guide and pick up where you left off.
</p>
<div className="mt-5">
<motion.button
type="button"
onClick={toggle}
aria-pressed={saved}
whileTap={reduced ? undefined : { scale: 0.96 }}
className={`group inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
saved
? "border-violet-500/40 bg-violet-500/10 text-violet-700 dark:text-violet-300"
: "border-slate-300 text-slate-700 hover:border-slate-400 dark:border-slate-700 dark:text-slate-200 dark:hover:border-slate-600"
}`}
>
<span className="relative flex">
<AnimatedIcon saved={saved} burst={burst} reduced={reduced}>
<BookmarkIcon filled={saved} className="h-4 w-4" />
</AnimatedIcon>
<Burst burst={burst} dot="bg-violet-400" ring="border-violet-400/60" />
</span>
<span>{saved ? "Saved" : "Save"}</span>
</motion.button>
</div>
</div>
);
}
function StarCountCard() {
const prefersReduced = useReducedMotion();
const reduced = !!prefersReduced;
const [saved, setSaved] = useState(true);
const [burst, setBurst] = useState(0);
const base = 1284;
const count = base + (saved ? 1 : 0);
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const next = e.target.checked;
setSaved(next);
if (next && !reduced) setBurst((b) => b + 1);
};
return (
<div className="rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900/60">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">Star this repository</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">nova-labs/analytics-sdk</p>
<label className="mt-5 inline-flex cursor-pointer items-stretch overflow-hidden rounded-lg border border-slate-300 text-sm font-medium dark:border-slate-700">
<input type="checkbox" checked={saved} onChange={onChange} className="peer sr-only" />
<span
className={`relative flex items-center gap-2 px-3 py-2 transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-inset peer-focus-visible:ring-sky-500/70 ${
saved
? "bg-sky-500/10 text-sky-700 dark:text-sky-300"
: "text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800/60"
}`}
>
<span className="relative flex">
<AnimatedIcon saved={saved} burst={burst} reduced={reduced}>
<StarIcon filled={saved} className="h-4 w-4" />
</AnimatedIcon>
<Burst burst={burst} dot="bg-sky-400" ring="border-sky-400/60" />
</span>
<span>{saved ? "Starred" : "Star"}</span>
</span>
<span className="flex items-center border-l border-slate-300 px-3 py-2 tabular-nums text-slate-600 dark:border-slate-700 dark:text-slate-300">
<span aria-live="polite">{count.toLocaleString()}</span>
</span>
<span className="sr-only">
{saved ? "Starred" : "Not starred"}, {count.toLocaleString()} stars
</span>
</label>
</div>
);
}
type Article = { title: string; tag: string; minutes: number; defaultSaved: boolean };
const ARTICLES: Article[] = [
{ title: "Designing resilient API rate limits", tag: "Engineering", minutes: 8, defaultSaved: true },
{ title: "The 2026 state of edge rendering", tag: "Research", minutes: 12, defaultSaved: false },
{ title: "How we cut LCP to 1.1s on mobile", tag: "Performance", minutes: 6, defaultSaved: false },
];
function ReadingRow({ title, tag, minutes, defaultSaved }: Article) {
const prefersReduced = useReducedMotion();
const reduced = !!prefersReduced;
const { saved, burst, toggle } = useSaveToggle(defaultSaved, reduced);
return (
<li className="flex items-center gap-4 py-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">{title}</p>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
{tag} · {minutes} min read
</p>
</div>
<motion.button
type="button"
onClick={toggle}
aria-pressed={saved}
aria-label={saved ? `Remove ${title} from reading list` : `Save ${title} to reading list`}
whileTap={reduced ? undefined : { scale: 0.9 }}
className={`relative grid h-9 w-9 shrink-0 place-items-center rounded-lg border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
saved
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "border-slate-200 text-slate-400 hover:text-slate-600 dark:border-slate-700 dark:text-slate-500 dark:hover:text-slate-300"
}`}
>
<AnimatedIcon saved={saved} burst={burst} reduced={reduced}>
<BookmarkIcon filled={saved} className="h-4 w-4" />
</AnimatedIcon>
<Burst burst={burst} dot="bg-emerald-400" ring="border-emerald-400/60" />
</motion.button>
</li>
);
}
function ReadingListCard() {
return (
<div className="mt-6 rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900/60">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">Reading list</h3>
<span className="text-xs text-slate-400 dark:text-slate-500">Tap the bookmark to save</span>
</div>
<ul className="mt-2 divide-y divide-slate-100 dark:divide-slate-800">
{ARTICLES.map((a) => (
<ReadingRow key={a.title} {...a} />
))}
</ul>
</div>
);
}
export default function StarToggle() {
return (
<section className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-28">
<style>{KEYFRAMES}</style>
<div className="mx-auto max-w-5xl">
<div className="max-w-2xl">
<span className="tglstar-shine inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
<StarIcon filled className="h-3.5 w-3.5 text-amber-400" />
Save & bookmark toggles
</span>
<h2 className="mt-4 text-2xl font-semibold tracking-tight sm:text-3xl">
Save states that feel good to tap
</h2>
<p className="mt-3 text-sm text-slate-500 dark:text-slate-400 sm:text-base">
Star, bookmark, and reading-list toggles that actually work — real buttons and inputs,
keyboard support, visible focus rings, <code className="rounded bg-slate-200/70 px-1 py-0.5 text-[0.8em] dark:bg-slate-800">aria-pressed</code>{" "}
state, and motion that respects reduced-motion.
</p>
</div>
<div className="mt-10 grid gap-6 sm:grid-cols-2">
<FavoriteStarCard />
<SaveArticleCard />
<StarCountCard />
</div>
<ReadingListCard />
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Checkbox Set Toggle
Originaldefault/checked/indeterminate/disabled checkboxes

Checkbox Custom Toggle
Originalcustom animated tick checkboxes

Checkbox Card Toggle
Originalselectable card checkboxes

Checkbox Indeterminate Toggle
Originalparent/child indeterminate checkboxes

Checkbox List Toggle
Originalcheckbox list with select-all

Radio Set Toggle
Originalstyled radio button group

Radio Cards Toggle
Originalselectable radio cards

Radio Segmented Toggle
Originalsegmented control radios

Switch Basic Toggle
Originalbasic on/off switches in sizes

Switch iOS Toggle
OriginaliOS-style switches
Switch Icons Toggle
Originalswitches with sun/moon style icons

Switch Labels Toggle
Originalswitches with on/off labels inside

