Web InnoventixFreeCode

Cloud Tag

Original · free

weighted tag cloud

byWeb InnoventixReact + Tailwind
tagcloudbadges
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/tag-cloud.json
tag-cloud.tsx
"use client";

import { useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";

type Metric = "articles" | "reads";
type SortKey = "weight" | "name" | "momentum";

interface Topic {
  label: string;
  articles: number;
  reads: number; // weekly reads, in thousands
  momentum: number; // week-over-week change, %
}

const TOPICS: Topic[] = [
  { label: "React", articles: 248, reads: 62.4, momentum: 12 },
  { label: "TypeScript", articles: 214, reads: 58.1, momentum: 18 },
  { label: "CSS", articles: 187, reads: 44.7, momentum: 9 },
  { label: "Accessibility", articles: 142, reads: 31.2, momentum: 34 },
  { label: "Performance", articles: 128, reads: 29.8, momentum: 21 },
  { label: "Animation", articles: 96, reads: 22.5, momentum: 15 },
  { label: "Node.js", articles: 88, reads: 19.3, momentum: 6 },
  { label: "Testing", articles: 76, reads: 15.7, momentum: 11 },
  { label: "GraphQL", articles: 64, reads: 12.1, momentum: -4 },
  { label: "Design Systems", articles: 58, reads: 14.9, momentum: 27 },
  { label: "WebGL", articles: 41, reads: 8.6, momentum: 19 },
  { label: "Rust", articles: 37, reads: 9.2, momentum: 41 },
  { label: "Docker", articles: 33, reads: 6.4, momentum: 3 },
  { label: "Security", articles: 29, reads: 7.1, momentum: 23 },
  { label: "SEO", articles: 24, reads: 5.8, momentum: 16 },
  { label: "PWA", articles: 18, reads: 3.2, momentum: -8 },
  { label: "WebAssembly", articles: 14, reads: 4.1, momentum: 38 },
  { label: "Edge Functions", articles: 11, reads: 3.9, momentum: 52 },
  { label: "SVG", articles: 9, reads: 2.3, momentum: 7 },
  { label: "Bun", articles: 6, reads: 2.8, momentum: 63 },
];

const SORTS: { value: SortKey; label: string }[] = [
  { value: "weight", label: "Largest" },
  { value: "name", label: "A–Z" },
  { value: "momentum", label: "Trending" },
];

const METRICS: { value: Metric; label: string }[] = [
  { value: "articles", label: "Articles" },
  { value: "reads", label: "Weekly reads" },
];

/* ---------- inline icons ---------- */

function SearchIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
      <path d="M20 20l-3.4-3.4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
    </svg>
  );
}

function CloseIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
      <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
    </svg>
  );
}

function PinIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
      <path
        d="M9 3h6l-1 5 3 3v2H7v-2l3-3-1-5Z"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinejoin="round"
      />
      <path d="M12 13v8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
    </svg>
  );
}

function RiseIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" style={{ width: "0.72em", height: "0.72em" }}>
      <path d="M5 15l5-5 3 3 6-7" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M15 6h4v4" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

/* ---------- accessible segmented control ---------- */

interface SegmentedProps<T extends string> {
  label: string;
  options: { value: T; label: string }[];
  value: T;
  onChange: (v: T) => void;
}

function Segmented<T extends string>({ label, options, value, onChange }: SegmentedProps<T>) {
  const refs = useRef<(HTMLButtonElement | null)[]>([]);

  const focusAt = (i: number) => {
    const n = (i + options.length) % options.length;
    onChange(options[n].value);
    refs.current[n]?.focus();
  };

  const onKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        e.preventDefault();
        focusAt(i + 1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        e.preventDefault();
        focusAt(i - 1);
        break;
      case "Home":
        e.preventDefault();
        focusAt(0);
        break;
      case "End":
        e.preventDefault();
        focusAt(options.length - 1);
        break;
      default:
        break;
    }
  };

  return (
    <div
      role="radiogroup"
      aria-label={label}
      className="inline-flex items-center gap-0.5 rounded-full border border-slate-200 bg-slate-100/70 p-0.5 dark:border-slate-800 dark:bg-slate-900/70"
    >
      {options.map((o, i) => {
        const active = o.value === value;
        return (
          <button
            key={o.value}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={active}
            tabIndex={active ? 0 : -1}
            onClick={() => onChange(o.value)}
            onKeyDown={(e) => onKey(e, i)}
            className={[
              "rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors",
              "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2",
              "focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-900",
              active
                ? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
                : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200",
            ].join(" ")}
          >
            {o.label}
          </button>
        );
      })}
    </div>
  );
}

/* ---------- main component ---------- */

export default function TagCloud() {
  const reduce = useReducedMotion();
  const uid = useId();
  const searchId = `${uid}-search`;

  const [metric, setMetric] = useState<Metric>("articles");
  const [sort, setSort] = useState<SortKey>("weight");
  const [query, setQuery] = useState("");
  const [pinned, setPinned] = useState<Set<string>>(() => new Set(["Accessibility", "Rust"]));

  const metricVal = (t: Topic) => (metric === "articles" ? t.articles : t.reads);

  const [minV, maxV] = useMemo(() => {
    const vals = TOPICS.map((t) => (metric === "articles" ? t.articles : t.reads));
    return [Math.min(...vals), Math.max(...vals)] as const;
  }, [metric]);

  const ordered = useMemo(() => {
    const arr = [...TOPICS];
    if (sort === "name") {
      arr.sort((a, b) => a.label.localeCompare(b.label));
    } else if (sort === "momentum") {
      arr.sort((a, b) => b.momentum - a.momentum);
    } else {
      const v = (t: Topic) => (metric === "articles" ? t.articles : t.reads);
      arr.sort((a, b) => v(b) - v(a));
    }
    return arr;
  }, [sort, metric]);

  const q = query.trim().toLowerCase();
  const matches = (t: Topic) => q === "" || t.label.toLowerCase().includes(q);
  const matchCount = q === "" ? TOPICS.length : TOPICS.filter(matches).length;

  const pinnedTopics = ordered.filter((t) => pinned.has(t.label));
  const pinnedArticles = pinnedTopics.reduce((s, t) => s + t.articles, 0);

  const togglePin = (label: string) => {
    setPinned((prev) => {
      const next = new Set(prev);
      if (next.has(label)) next.delete(label);
      else next.add(label);
      return next;
    });
  };

  const eased = (t: Topic) => {
    const v = metricVal(t);
    const norm = maxV === minV ? 1 : (v - minV) / (maxV - minV);
    return Math.sqrt(norm); // compress so the smallest tags stay legible
  };

  const tierClass = (e: number) => {
    if (e >= 0.72) return "text-indigo-700 hover:bg-indigo-50 dark:text-indigo-300 dark:hover:bg-indigo-500/10";
    if (e >= 0.45) return "text-violet-700 hover:bg-violet-50 dark:text-violet-300 dark:hover:bg-violet-500/10";
    if (e >= 0.22) return "text-sky-700 hover:bg-sky-50 dark:text-sky-300 dark:hover:bg-sky-500/10";
    return "text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800/70";
  };

  const badgeText = (t: Topic) => (metric === "articles" ? String(t.articles) : `${t.reads}k`);

  const container = {
    hidden: {},
    show: { transition: { staggerChildren: 0.022 } },
  };
  const item = {
    hidden: { opacity: 0, scale: 0.82, y: 10 },
    show: { opacity: 1, scale: 1, y: 0, transition: { type: "spring" as const, stiffness: 420, damping: 30 } },
  };

  const chipAnim = reduce
    ? {}
    : { initial: { opacity: 0, scale: 0.7 }, animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: 0.7 } };

  return (
    <section className="relative w-full overflow-hidden bg-white px-6 py-20 text-slate-900 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes tagcloud-drift {
          0%, 100% { transform: translate3d(0,0,0) scale(1); }
          50% { transform: translate3d(0,-22px,0) scale(1.06); }
        }
        @keyframes tagcloud-pulse {
          0%, 100% { opacity: 0.4; transform: scale(1); }
          50% { opacity: 1; transform: scale(1.35); }
        }
        .tagcloud-blob { animation: tagcloud-drift 15s ease-in-out infinite; }
        .tagcloud-blob--b { animation-duration: 21s; animation-direction: reverse; }
        .tagcloud-dot { animation: tagcloud-pulse 2.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .tagcloud-blob, .tagcloud-dot { animation: none !important; }
        }
      `}</style>

      {/* ambient background */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="tagcloud-blob absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20" />
        <div className="tagcloud-blob tagcloud-blob--b absolute -bottom-28 right-0 h-80 w-80 rounded-full bg-violet-300/30 blur-3xl dark:bg-violet-700/20" />
        <div
          className="absolute inset-0 opacity-[0.04] dark:opacity-[0.06]"
          style={{
            backgroundImage:
              "linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)",
            backgroundSize: "44px 44px",
          }}
        />
      </div>

      <div className="relative mx-auto max-w-5xl">
        {/* header */}
        <div className="flex flex-col gap-4">
          <span className="inline-flex items-center gap-2 self-start rounded-full border border-slate-200 bg-white/60 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.18em] text-slate-500 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-400">
            <span className="tagcloud-dot inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Topic index
          </span>
          <h2 className="max-w-2xl text-3xl font-semibold tracking-tight sm:text-4xl">
            Everything we publish, sized by how much you read it
          </h2>
          <p className="max-w-2xl text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A weighted cloud of every subject in the archive. Larger means deeper coverage. Re-weight it, sort it,
            filter it, and pin the topics you want kept close.
          </p>
        </div>

        {/* controls */}
        <div className="mt-9 flex flex-col gap-4 rounded-2xl border border-slate-200 bg-slate-50/60 p-4 sm:flex-row sm:items-end sm:justify-between dark:border-slate-800 dark:bg-slate-900/40">
          <div className="flex flex-wrap items-end gap-6">
            <div className="flex flex-col gap-1.5">
              <span className="text-xs font-medium text-slate-500 dark:text-slate-400">Weight by</span>
              <Segmented label="Weight topics by" options={METRICS} value={metric} onChange={setMetric} />
            </div>
            <div className="flex flex-col gap-1.5">
              <span className="text-xs font-medium text-slate-500 dark:text-slate-400">Sort</span>
              <Segmented label="Sort topics by" options={SORTS} value={sort} onChange={setSort} />
            </div>
          </div>

          <div className="flex flex-col gap-1.5">
            <label htmlFor={searchId} className="text-xs font-medium text-slate-500 dark:text-slate-400">
              Filter
            </label>
            <div className="relative">
              <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500">
                <SearchIcon />
              </span>
              <input
                id={searchId}
                type="search"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === "Escape") setQuery("");
                }}
                placeholder="Search topics…"
                className="w-full rounded-full border border-slate-200 bg-white py-2 pl-9 pr-9 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 sm:w-64 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-slate-900"
              />
              {query !== "" && (
                <button
                  type="button"
                  onClick={() => setQuery("")}
                  aria-label="Clear filter"
                  className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-slate-800 dark:hover:text-slate-200"
                >
                  <CloseIcon />
                </button>
              )}
            </div>
          </div>
        </div>

        {/* live status */}
        <p role="status" aria-live="polite" className="mt-5 text-sm text-slate-500 dark:text-slate-400">
          {q === ""
            ? `${TOPICS.length} topics · weighted by ${metric === "articles" ? "articles published" : "weekly reads"}`
            : `${matchCount} of ${TOPICS.length} topics match “${query.trim()}”`}
        </p>

        {/* the cloud */}
        <LayoutGroup>
          <motion.div
            role="group"
            aria-label="Topic tags. Activate a tag to pin it to your feed."
            className="mt-4 flex flex-wrap items-baseline gap-x-2.5 gap-y-3"
            {...(reduce ? {} : { variants: container, initial: "hidden", animate: "show" })}
          >
            {ordered.map((t) => {
              const e = eased(t);
              const size = 0.9 + e * 1.7; // rem
              const weight = 440 + Math.round(e * 320);
              const isPinned = pinned.has(t.label);
              const dim = q !== "" && !matches(t);
              const rising = t.momentum >= 30;

              return (
                <motion.button
                  key={t.label}
                  type="button"
                  aria-pressed={isPinned}
                  aria-label={`${t.label}, ${t.articles} articles, ${t.momentum >= 0 ? "up" : "down"} ${Math.abs(
                    t.momentum,
                  )} percent this week${isPinned ? ", pinned" : ""}`}
                  onClick={() => togglePin(t.label)}
                  style={{ fontSize: `${size}rem`, fontWeight: weight }}
                  {...(reduce ? {} : { variants: item, layout: true, whileHover: { scale: 1.06 }, whileTap: { scale: 0.95 } })}
                  className={[
                    "group inline-flex items-baseline gap-1 rounded-full px-3 py-1 leading-none tracking-tight transition-colors",
                    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2",
                    "focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
                    dim ? "opacity-25" : "opacity-100",
                    isPinned
                      ? "bg-indigo-600 text-white shadow-sm shadow-indigo-600/30 dark:bg-indigo-500"
                      : tierClass(e),
                  ].join(" ")}
                >
                  <span>{t.label}</span>
                  {rising && !isPinned && (
                    <span className="self-center text-emerald-500 dark:text-emerald-400">
                      <RiseIcon />
                    </span>
                  )}
                  <span
                    className={[
                      "self-start tabular-nums",
                      isPinned ? "text-indigo-100" : "text-slate-400 dark:text-slate-500",
                    ].join(" ")}
                    style={{ fontSize: "0.58em", fontWeight: 600 }}
                  >
                    {badgeText(t)}
                  </span>
                </motion.button>
              );
            })}
          </motion.div>
        </LayoutGroup>

        {/* pinned feed */}
        <div className="mt-12 rounded-2xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-5 dark:border-slate-800 dark:from-slate-900/60 dark:to-slate-950">
          <div className="flex flex-wrap items-center justify-between gap-3">
            <div className="flex items-center gap-2.5">
              <span className="grid h-8 w-8 place-items-center rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
                <PinIcon />
              </span>
              <div>
                <p className="text-sm font-semibold">Your pinned feed</p>
                <p className="text-xs text-slate-500 dark:text-slate-400">
                  {pinnedTopics.length === 0
                    ? "Pin a topic above to start building your feed"
                    : `${pinnedTopics.length} ${pinnedTopics.length === 1 ? "topic" : "topics"} · ${pinnedArticles} articles`}
                </p>
              </div>
            </div>
            {pinnedTopics.length > 0 && (
              <button
                type="button"
                onClick={() => setPinned(new Set())}
                className="rounded-full border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-offset-slate-950"
              >
                Clear all
              </button>
            )}
          </div>

          <div className="mt-4 flex min-h-[2.25rem] flex-wrap gap-2">
            <AnimatePresence initial={false}>
              {pinnedTopics.length === 0 ? (
                <motion.p
                  key="empty"
                  {...chipAnim}
                  className="text-sm text-slate-400 dark:text-slate-500"
                >
                  Nothing pinned yet.
                </motion.p>
              ) : (
                pinnedTopics.map((t) => (
                  <motion.span
                    key={t.label}
                    layout={reduce ? false : true}
                    {...chipAnim}
                    className="inline-flex items-center gap-1.5 rounded-full bg-indigo-600 py-1 pl-3 pr-1.5 text-sm font-medium text-white dark:bg-indigo-500"
                  >
                    {t.label}
                    <button
                      type="button"
                      onClick={() => togglePin(t.label)}
                      aria-label={`Unpin ${t.label}`}
                      className="grid h-5 w-5 place-items-center rounded-full text-indigo-100 transition-colors hover:bg-indigo-500 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 dark:hover:bg-indigo-400"
                    >
                      <CloseIcon />
                    </button>
                  </motion.span>
                ))
              )}
            </AnimatePresence>
          </div>
        </div>
      </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 →