# Standings Feature

> 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.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/standings-feature).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/standings-feature
- Screenshot: https://shadcn.exchange/shot/keyframeui/standings-feature/light.png
- Registry item: https://keyframeui.com/r/standings-feature.json
- Categories: blocks, feature-sections
- Listed: 2026-11-02, source last changed 2026-11-02

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/keyframeui/standings-feature.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/standings.json`, `utils`

## Notes from the registry

Live preview and the components it's built with: https://keyframeui.com/blocks/feature-sections/standings-feature

## Files

### `registry/blocks/standings-feature.tsx`

```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>
  );
}
```
