Skip to content
AI & Chat primitive

Citation Chip

A numbered source reference whose accessible name is the source rather than the numeral — because eight links all called a digit cannot be told apart in a screen reader link list, which is how many people navigate a page.

134 linesNo dependenciesAdded 14 Sept 2026
  • citation
  • source
  • reference
  • rag
  • grounding
  • footnote

What's included

  • components/ui/citation-chip.tsx
  • No runtime dependencies

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add citation-chip

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

The controller reverted after two failed probes1, which matches the documented threshold2.

Release controller log, 02:14

internal · logs.example.com

probe failed (2/2) — reverting to revision 481 and holding traffic on the previous pod set.

Source

One file. Create components/ui/citation-chip.tsx and paste.

components/ui/citation-chip.tsx
/**
 * <CitationChip> — one numbered source reference, inline in the prose or as
 * a card under it.
 *
 * **The numeral is not the accessible name.** A citation rendered as
 * `<sup><a href="…">1</a></sup>` is announced as "link, one". In a list of
 * links — which is how many people navigate a page — it is one of eight
 * entries all called a digit, and choosing between them is impossible. So
 * the visible text stays the numeral the prose refers to, and the
 * `aria-label` carries what the link actually goes to.
 *
 * **`<cite>` is the element for the title of a referenced work.** It is one
 * of the few HTML elements that means precisely the thing being marked up
 * here, and it costs nothing to use correctly.
 *
 * **There is no hover card.** The obvious design puts the snippet in a
 * tooltip, which is unreachable by touch, needs a focus and dismissal
 * contract to satisfy 1.4.13, and hides the one piece of text that would
 * let a reader judge the source without leaving the page. The `card`
 * variant puts the snippet on the page instead. If you want the popover,
 * build it around this — but the default should not be the version that
 * only works with a mouse.
 *
 * **New tabs announce themselves.** `target="_blank"` without a warning is
 * a 3.2.5 failure: the back button stops working and nobody said why.
 */

import * as React from 'react'

export interface CitationChipProps {
  /** The number the prose refers to. Rendered visibly; never the whole name. */
  index: number
  title: string
  href?: string
  /** The publication or domain — "arxiv.org", "Internal wiki". */
  source?: string
  /** Shown in the `card` variant, ignored inline. */
  snippet?: string
  variant?: 'inline' | 'card'
  /** Opens in a new tab, with the warning that obliges. */
  newTab?: boolean
  className?: string
}

export function CitationChip({
  index,
  title,
  href,
  source,
  snippet,
  variant = 'inline',
  newTab = false,
  className = '',
}: CitationChipProps) {
  const accessibleName = source ? `Source ${index}: ${title}, ${source}` : `Source ${index}: ${title}`
  const external = newTab && href ? { target: '_blank', rel: 'noreferrer' } : {}

  if (variant === 'inline') {
    const chip = (
      <>
        {index}
        <span className="sr-only">
          {` ${accessibleName}`}
          {newTab && href ? ' (opens in a new tab)' : ''}
        </span>
      </>
    )

    return (
      <sup className={['mx-0.5 align-super', className].filter(Boolean).join(' ')}>
        {href ? (
          <a
            href={href}
            aria-label={accessibleName + (newTab ? ' (opens in a new tab)' : '')}
            {...external}
            className="inline-grid size-[1.15em] place-items-center rounded-[0.25em] bg-muted text-[0.7em] font-medium leading-none tabular-nums text-muted-foreground no-underline transition-colors hover:bg-primary hover:text-primary-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
          >
            {index}
          </a>
        ) : (
          <span className="inline-grid size-[1.15em] place-items-center rounded-[0.25em] bg-muted text-[0.7em] font-medium leading-none tabular-nums text-muted-foreground">
            {chip}
          </span>
        )}
      </sup>
    )
  }

  return (
    <div
      className={[
        // `relative` is load-bearing: the title link stretches over the whole
        // card with `after:inset-0`, and an absolute child with no positioned
        // ancestor anchors to the page instead.
        'relative flex gap-2.5 rounded-lg border border-border bg-card p-2.5',
        href ? 'transition-colors hover:border-primary/40' : '',
        className,
      ]
        .filter(Boolean)
        .join(' ')}
    >
      <span
        aria-hidden="true"
        className="mt-0.5 grid size-5 shrink-0 place-items-center rounded bg-muted text-[11px] font-medium tabular-nums text-muted-foreground"
      >
        {index}
      </span>

      <div className="min-w-0 flex-1">
        <cite className="block truncate text-sm font-medium not-italic text-foreground">
          {href ? (
            <a
              href={href}
              aria-label={accessibleName + (newTab ? ' (opens in a new tab)' : '')}
              {...external}
              className="no-underline outline-none after:absolute after:inset-0 hover:underline focus-visible:underline"
            >
              {title}
            </a>
          ) : (
            title
          )}
        </cite>
        {source ? <p className="mt-0.5 truncate text-xs text-muted-foreground">{source}</p> : null}
        {snippet ? (
          <p className="mt-1.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
            {snippet}
          </p>
        ) : null}
      </div>
    </div>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
indexrequiredThe number the prose refers to. Rendered visibly; never the whole name.number—
titlerequiredstring—
hrefstring—
sourceThe publication or domain — "arxiv.org", "Internal wiki".string—
snippetShown in the `card` variant, ignored inline.string—
variant'inline' | 'card''inline'
newTabOpens in a new tab, with the warning that obliges.booleanfalse
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More ai & chat

View all
Open the full page for this primitive

Message Bubble

One turn in a thread, with the part every chat UI leaves out: the speaker is in the text rather than implied by which side the bubble sits on, so a screen reader hears a conversation instead of an unattributed monologue.

AI & Chat178 linesNo deps
Open the full page for this primitive

Prompt Input

The composer, reduced to the four things that are actually hard: Enter that does not fire mid-IME-composition, one submit path for button and keyboard alike, stop as a separate button from send, and a row cap measured from the real line height.

AI & Chat177 lines1 dep
Open the full page for this primitive

Tool Call

A single tool invocation that opens into its arguments and result — status carried by a glyph and a word rather than a colour, arguments as a definition list, and a duration formatted arithmetically so it cannot drift at hydration.

AI & Chat202 lines1 dep