A keyword-tracking feature section. On the left, the keywords an app could rank for, each with its monthly searches, the app's rank and how far it moved; on the right, the race for the picked keyword. Pick another and the same six apps slide to their new places, with a suggestion for how to climb under them. The keywords play by themselves, a line under the current one filling like a timer, until the visitor picks one.
registry/blocks/standings-feature.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Standings, type StandingsEntry } from "@/components/ui/standings";
/* ── Content ─────────────────────────────────────────────────
* Everything you'd change to make the section yours: the copy,
* the apps, and the keywords. Each keyword holds every app's
* share of the installs that come from that search; pick one
* and the apps race to its order. Your rank in the list is
* worked out from the same numbers, so the two always agree.
* ─────────────────────────────────────────────────────────── */
/** The one accent: buttons, your row, the picked keyword and its timer. */
const ACCENT = "#ff5a1f";
const BADGE = "Keyword tracking";
const HEADLINE = "See which searches you can win.";
const SUBLINE =
"Every keyword your app could rank for, how many people search it each month, and who's ahead of you. Pick one to watch its race.";
const PRIMARY_CTA = { label: "Track my keywords", href: "#start" };
const SECONDARY_CTA = { label: "See a sample report", href: "#sample" };
const LIST = { title: "Keywords", note: "5 of 214 · by searches", columns: ["Keyword", "Searches", "Rank", "30 days"] };
const PANEL = { prefix: "Results for", unit: "Share of installs from this search" };
const SOURCES = { label: "Rankings from", items: ["App Store", "Google Play", "Any CSV"] };
/** ms each keyword stays up while the list plays by itself, until a visitor picks one. */
const CYCLE = 4200;
type App = { id: string; label: string; detail: string; from: string; to: string; you?: boolean };
const APPS: App[] = [
{ id: "fieldnote", label: "Fieldnote", detail: "Notes that file themselves", from: "#ff8a3d", to: "#ff3d00", you: true },
{ id: "tally", label: "Tally Notes", detail: "Notes & lists", from: "#4f8bff", to: "#1d4ed8" },
{ id: "scribe", label: "Scribe", detail: "Voice to text", from: "#a78bfa", to: "#6d28d9" },
{ id: "margin", label: "Margin", detail: "Linked notes", from: "#34d399", to: "#059669" },
{ id: "quillbox", label: "Quillbox", detail: "Handwriting", from: "#fb7185", to: "#e11d48" },
{ id: "inkpad", label: "Inkpad", detail: "Sketch & annotate", from: "#fbbf24", to: "#ea580c" },
];
type Keyword = {
id: string;
label: string;
/** Monthly searches. */
searches: number;
/** Places your rank moved in the last 30 days. */
moved: number;
/** Each app's share of the installs from this search, in percent. */
shares: Record<string, number>;
/** What Rung suggests changing to climb this one. */
tip: string;
};
const KEYWORDS: Keyword[] = [
{
id: "notes-app",
label: "notes app",
searches: 90_000,
moved: 2,
shares: { tally: 31, scribe: 24, fieldnote: 17, margin: 12, quillbox: 9, inkpad: 7 },
tip: "Put “notes app” in your subtitle. The two apps above you both have it.",
},
{
id: "meeting-notes",
label: "meeting notes",
searches: 36_000,
moved: 1,
shares: { fieldnote: 34, scribe: 22, tally: 18, margin: 11, inkpad: 9, quillbox: 6 },
tip: "You lead this one. Keep the meeting screenshot first in your gallery.",
},
{
id: "voice-memo",
label: "voice memo to text",
searches: 22_000,
moved: 4,
shares: { scribe: 29, fieldnote: 27, quillbox: 19, tally: 12, inkpad: 8, margin: 5 },
tip: "Two points behind Scribe. Mention transcription in the first line of your description.",
},
{
id: "second-brain",
label: "second brain",
searches: 14_000,
moved: -1,
shares: { margin: 36, tally: 21, scribe: 17, inkpad: 12, fieldnote: 9, quillbox: 5 },
tip: "Margin owns this term. Spend the keyword field on “meeting notes” instead.",
},
{
id: "handwriting",
label: "handwriting notes",
searches: 9_000,
moved: 0,
shares: { inkpad: 38, quillbox: 23, fieldnote: 18, scribe: 11, tally: 6, margin: 4 },
tip: "Holding third. A screenshot of pencil input could take second from Quillbox.",
},
];
const compact = new Intl.NumberFormat("en-US", { notation: "compact" });
const top = Math.max(...KEYWORDS.map((k) => k.searches));
const youId = APPS.find((app) => app.you)?.id ?? "";
/** Your place for a keyword, from its shares. */
function placeFor(keyword: Keyword) {
return [...APPS].sort((a, b) => keyword.shares[b.id] - keyword.shares[a.id]).findIndex((app) => app.id === youId) + 1;
}
const reducedQuery = "(prefers-reduced-motion: reduce)";
/* ── Section ─────────────────────────────────────────────── */
export function StandingsFeature() {
const [active, setActive] = React.useState(0);
const [picked, setPicked] = React.useState(false);
const [auto, setAuto] = React.useState(false);
const sectionRef = React.useRef<HTMLElement>(null);
const tabs = React.useRef<(HTMLButtonElement | null)[]>([]);
const timer = React.useRef<HTMLSpanElement>(null);
const keyword = KEYWORDS[active];
// The list plays by itself while it's on screen, until a visitor picks a keyword.
React.useEffect(() => {
const el = sectionRef.current;
if (!el || picked || window.matchMedia(reducedQuery).matches) return;
const io = new IntersectionObserver(([entry]) => setAuto(entry.isIntersecting), { threshold: 0.35 });
io.observe(el);
return () => io.disconnect();
}, [picked]);
React.useEffect(() => {
if (!auto || picked) return;
const bar = timer.current?.animate([{ transform: "scaleX(0)" }, { transform: "scaleX(1)" }], {
duration: CYCLE,
easing: "linear",
fill: "forwards",
});
const id = window.setTimeout(() => setActive((i) => (i + 1) % KEYWORDS.length), CYCLE);
return () => {
window.clearTimeout(id);
bar?.cancel();
};
}, [auto, picked, active]);
const pick = (i: number) => {
setPicked(true);
setActive(i);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (!step) return;
e.preventDefault();
const next = (active + step + KEYWORDS.length) % KEYWORDS.length;
pick(next);
tabs.current[next]?.focus();
};
const entries = React.useMemo<StandingsEntry[]>(
() =>
APPS.map((app) => ({
id: app.id,
label: app.label,
detail: app.detail,
you: app.you,
value: keyword.shares[app.id],
icon: (
<span
className="grid size-full place-items-center text-[13px] font-semibold text-white"
style={{ backgroundImage: `linear-gradient(145deg, ${app.from}, ${app.to})` }}
>
{app.label[0]}
</span>
),
})),
[keyword],
);
return (
<section
ref={sectionRef}
style={{ "--accent": ACCENT } as React.CSSProperties}
className="bg-[#f5f5f6] px-3 py-20 text-zinc-950 sm:px-4 sm:py-28 dark:bg-[#09090b] dark:text-white"
>
<div className="mx-auto max-w-2xl px-3 text-center">
<p className="inline-flex items-center gap-1.5 rounded-full bg-white px-2.5 py-1 text-xs font-medium text-zinc-700 shadow-[0_0_0_1px_rgb(0_0_0/0.06)] dark:bg-white/[0.06] dark:text-zinc-300 dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
<SearchGlyph className="size-3.5 text-(--accent)" />
{BADGE}
</p>
<h2 className="mt-5 text-[2rem] leading-[1.1] font-semibold tracking-[-0.035em] text-balance sm:text-5xl">{HEADLINE}</h2>
<p className="mx-auto mt-4 max-w-lg text-[15px] leading-relaxed text-pretty text-zinc-600 sm:text-base dark:text-zinc-400">{SUBLINE}</p>
<div className="mt-7 flex flex-wrap items-center justify-center gap-2.5">
<a
href={PRIMARY_CTA.href}
className="flex h-10 items-center gap-1 rounded-full bg-(--accent) pr-3.5 pl-4 text-sm font-semibold text-white transition-[filter,transform] duration-150 hover:brightness-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) active:scale-[0.97]"
>
{PRIMARY_CTA.label}
<Chevron className="size-3.5" />
</a>
<a
href={SECONDARY_CTA.href}
className="flex h-10 items-center rounded-full bg-white px-4 text-sm font-semibold shadow-[0_1px_2px_rgb(0_0_0/0.05),0_0_0_1px_rgb(0_0_0/0.08)] transition-colors duration-150 hover:bg-zinc-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) dark:bg-white/[0.06] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.1)] dark:hover:bg-white/[0.09]"
>
{SECONDARY_CTA.label}
</a>
</div>
</div>
<div className="mx-auto mt-14 grid max-w-6xl overflow-hidden rounded-[24px] bg-white shadow-[0_1px_2px_rgb(0_0_0/0.04),0_0_0_1px_rgb(0_0_0/0.05),0_24px_60px_-32px_rgb(0_0_0/0.2)] lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] dark:bg-[#111113] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.07)]">
{/* The keywords: a vertical tab list that drives the race on the right. */}
<div className="flex flex-col border-b border-zinc-950/[0.07] p-2 sm:p-3 lg:border-r lg:border-b-0 dark:border-white/[0.07]">
<div className="flex items-baseline justify-between px-3 pt-2 pb-3">
<p className="text-[13px] font-semibold tracking-[-0.01em]">{LIST.title}</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">{LIST.note}</p>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_5.5rem_3rem] gap-3 border-b border-zinc-950/[0.07] px-3 pb-2 text-[11px] font-medium text-zinc-500 sm:grid-cols-[minmax(0,1fr)_7.5rem_3rem_3.5rem] dark:border-white/[0.07] dark:text-zinc-400">
{LIST.columns.map((column, i) => (
<span key={column} className={cn(i > 1 && "text-right", i === 3 && "hidden sm:block")}>
{column}
</span>
))}
</div>
<div role="tablist" aria-orientation="vertical" aria-label={LIST.title} onKeyDown={onKeyDown} className="mt-1 flex flex-col gap-0.5">
{KEYWORDS.map((k, i) => {
const selected = i === active;
const place = placeFor(k);
const filled = Math.max(1, Math.round((k.searches / top) * 5));
return (
<button
key={k.id}
ref={(el) => {
tabs.current[i] = el;
}}
type="button"
role="tab"
id={`rung-keyword-${k.id}`}
aria-selected={selected}
aria-controls="rung-keyword-panel"
tabIndex={selected ? 0 : -1}
onClick={() => pick(i)}
className={cn(
"relative grid h-12 grid-cols-[minmax(0,1fr)_5.5rem_3rem] items-center gap-3 overflow-hidden rounded-xl px-3 text-left transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-(--accent) sm:grid-cols-[minmax(0,1fr)_7.5rem_3rem_3.5rem]",
selected ? "bg-zinc-950/[0.04] dark:bg-white/[0.06]" : "hover:bg-zinc-950/[0.02] dark:hover:bg-white/[0.03]",
)}
>
<span className="flex min-w-0 items-center gap-2.5">
<SearchGlyph className={cn("size-3.5 shrink-0", selected ? "text-(--accent)" : "text-zinc-400 dark:text-zinc-500")} />
<span className={cn("truncate text-sm", selected ? "font-semibold" : "font-medium text-zinc-700 dark:text-zinc-300")}>{k.label}</span>
</span>
<span className="flex items-center gap-2">
<span className="flex gap-[3px]" aria-hidden>
{Array.from({ length: 5 }, (_, s) => (
<span key={s} className={cn("h-3 w-[5px] rounded-[1.5px]", s < filled ? "bg-emerald-500 dark:bg-emerald-400" : "bg-zinc-950/10 dark:bg-white/12")} />
))}
</span>
<span className="text-xs text-zinc-600 tabular-nums dark:text-zinc-400">{compact.format(k.searches)}</span>
</span>
<span className={cn("text-right text-sm font-semibold tabular-nums", place === 1 && "text-(--accent)")}>#{place}</span>
<span
className={cn(
"hidden items-center justify-end gap-0.5 text-xs font-semibold tabular-nums sm:flex",
k.moved > 0 ? "text-emerald-600 dark:text-emerald-400" : k.moved < 0 ? "text-rose-600 dark:text-rose-400" : "text-zinc-400",
)}
>
{k.moved === 0 ? "–" : (
<>
<svg viewBox="0 0 8 8" className={cn("size-2", k.moved < 0 && "rotate-180")} fill="currentColor" aria-hidden>
<path d="M4 1.2 7.2 6.4H.8Z" />
</svg>
{Math.abs(k.moved)}
</>
)}
<span className="sr-only">{k.moved > 0 ? " places up" : k.moved < 0 ? " place down" : " no change"}</span>
</span>
{selected ? (
<>
<span className="absolute inset-y-2.5 left-0 w-[3px] rounded-full bg-(--accent)" aria-hidden />
{auto && !picked ? (
<span ref={timer} className="absolute inset-x-3 bottom-0 h-px origin-left scale-x-0 bg-(--accent)" aria-hidden />
) : null}
</>
) : null}
</button>
);
})}
</div>
<p className="mt-auto flex flex-wrap items-center gap-1.5 px-3 pt-4 pb-1.5 text-xs text-zinc-500 dark:text-zinc-400">
{SOURCES.label}
{SOURCES.items.map((item) => (
<span key={item} className="rounded-md px-1.5 py-0.5 font-medium text-zinc-700 shadow-[0_0_0_1px_rgb(0_0_0/0.08)] dark:text-zinc-300 dark:shadow-[0_0_0_1px_rgb(255_255_255/0.1)]">
{item}
</span>
))}
</p>
</div>
{/* The race for the picked keyword. */}
<div id="rung-keyword-panel" role="tabpanel" aria-labelledby={`rung-keyword-${keyword.id}`} className="flex flex-col p-2 sm:p-3">
<div className="px-3 pt-2 pb-3">
<p className="truncate text-[13px] font-semibold tracking-[-0.01em]">
{PANEL.prefix} “{keyword.label}”
</p>
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">{PANEL.unit}</p>
</div>
<Standings
aria-label={`${PANEL.prefix} “${keyword.label}”`}
entries={entries}
max={40}
change={false}
format={(v) => `${Math.round(v)}%`}
className="border-t border-zinc-950/[0.07] [--standings-row:3rem] [--standings-surface:#ffffff] dark:border-white/[0.07] dark:[--standings-surface:#111113]"
/>
<p
key={keyword.id}
className="mx-1 mt-3 mb-1 flex gap-2.5 rounded-xl bg-[color-mix(in_oklab,var(--accent)_7%,transparent)] px-3 py-2.5 text-[13px] leading-snug text-zinc-700 transition-opacity duration-300 starting:opacity-0 dark:text-zinc-300"
>
<SparkGlyph className="mt-0.5 size-3.5 shrink-0 text-(--accent)" />
{keyword.tip}
</p>
</div>
</div>
</section>
);
}
/* ── Glyphs ──────────────────────────────────────────────── */
function SearchGlyph({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" className={className} fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden>
<circle cx="7" cy="7" r="4.25" />
<path d="m10.2 10.2 3.3 3.3" />
</svg>
);
}
function SparkGlyph({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" className={className} fill="currentColor" aria-hidden>
<path d="M8 1.5c.3 2.9 1.6 4.2 4.5 4.5-2.9.3-4.2 1.6-4.5 4.5-.3-2.9-1.6-4.2-4.5-4.5 2.9-.3 4.2-1.6 4.5-4.5ZM12.5 10c.15 1.4.8 2.05 2.2 2.2-1.4.15-2.05.8-2.2 2.2-.15-1.4-.8-2.05-2.2-2.2 1.4-.15 2.05-.8 2.2-2.2Z" />
</svg>
);
}
function Chevron({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" className={className} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m6 4 4 4-4 4" />
</svg>
);
}
Live preview and the components it's built with: https://keyframeui.com/blocks/feature-sections/standings-feature