Web InnoventixFreeCode

Star Toggle Toggle

Original · free

bookmark/star save toggle

byWeb InnoventixReact + Tailwind
tglstartoggletoggles
Open

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.json
tgl-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 &ldquo;Nova Analytics&rdquo; 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 &amp; 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 quote

Similar components

Browse all →