AI

Citation

A numbered source marker inside assistant prose with a hover card, plus the matching source list.

Built on
Popover APIpopover="hint"CSS anchor positioning
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.0 kB
Dependencies
React

Build 412 lowered the pool limit to 201ChangelogRelease notes for build 412pool.max now defaults to 20 connections., while peak traffic needs about 352Metricsapi-gateway connections, last 7 daysPeak of 34 open connections on Tuesday at 14:02..

Code
import { Citation } from "@/components/equip/citation";

export default function Demo() {
  return (
    <p style={{ width: 224, margin: 0, fontSize: "var(--eq-text-m)", lineHeight: 1.55 }}>
      Build 412 lowered the pool limit to 20
      <Citation
        index={1}
        href="#changelog"
        title="Release notes for build 412"
        source="Changelog"
        snippet="pool.max now defaults to 20 connections."
      />
      , while peak traffic needs about 35
      <Citation
        index={2}
        href="#dashboard"
        title="api-gateway connections, last 7 days"
        source="Metrics"
        snippet="Peak of 34 open connections on Tuesday at 14:02."
      />
      .
    </p>
  );
}

Examples

Answer with a source list

Markers and list entries share numbers, and each marker is announced as "Source 1" followed by the title.

The outage began when build 412 reached eu-west-11Status pageIncident 2291: elevated 5xx on api-gatewayErrors began at 14:02 UTC, two minutes after the rollout started.. Rolling back to 411 restored the error rate within four minutes2Engineering wikiRollback runbook, and the fix ships in build 4133Pull request 8812Raise pool.max to 50Restores the previous limit and adds an alert at 80% use..

Code
import { Citation, CitationList, CitationListItem } from "@/components/equip/citation";

export default function Example() {
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 16, width: "100%", maxWidth: 520, minWidth: 0, lineHeight: 1.55 }}>
      <p style={{ margin: 0 }}>
        The outage began when build 412 reached eu-west-1
        <Citation
          index={1}
          href="#incident"
          title="Incident 2291: elevated 5xx on api-gateway"
          source="Status page"
          snippet="Errors began at 14:02 UTC, two minutes after the rollout started."
        />
        . Rolling back to 411 restored the error rate within four minutes
        <Citation index={2} href="#runbook" title="Rollback runbook" source="Engineering wiki" />
        , and the fix ships in build 413
        <Citation
          index={3}
          href="#pr"
          title="Raise pool.max to 50"
          source="Pull request 8812"
          snippet="Restores the previous limit and adds an alert at 80% use."
        />
        .
      </p>
      <CitationList>
        <CitationListItem index={1} href="#incident" title="Incident 2291: elevated 5xx on api-gateway" source="Status page" />
        <CitationListItem index={2} href="#runbook" title="Rollback runbook" source="Engineering wiki" />
        <CitationListItem index={3} href="#pr" title="Raise pool.max to 50" source="Pull request 8812" />
      </CitationList>
    </div>
  );
}

One source cited twice

Reuse the same index when two claims rest on one document; the list keeps a single entry for it.

Check replication lag before promoting the replica1Engineering wikiDatabase failover runbookPromote the replica only after replication lag is under 5 seconds.. If lag stays above five seconds, wait rather than force the failover1Engineering wikiDatabase failover runbookPromote the replica only after replication lag is under 5 seconds..

Code
import { Citation } from "@/components/equip/citation";

export default function Example() {
  const runbook = {
    href: "#runbook",
    title: "Database failover runbook",
    source: "Engineering wiki",
    snippet: "Promote the replica only after replication lag is under 5 seconds.",
  };
  return (
    <p style={{ margin: 0, maxWidth: 480, lineHeight: 1.55 }}>
      Check replication lag before promoting the replica
      <Citation index={1} {...runbook} />
      . If lag stays above five seconds, wait rather than force the failover
      <Citation index={1} {...runbook} />.
    </p>
  );
}

Props

PropTypeDefaultWhat it does
indexnumbernoneThe number shown, matching the entry in CitationList.
hrefstringnoneWhere the source lives.
titlestringnoneTitle of the source. Part of the accessible name, "Source 1: title".
sourcestringnoneSite or document name shown in the card.
snippetstringnoneThe passage the claim rests on, shown in the card.
...restComponentProps<"a">noneEvery native anchor attribute, such as target or rel.
CitationList headingReactNode"Sources"Label above the list; also names the list.
CitationListItem{ index, href, title, source? }noneOne numbered entry, a link to the source.

Anatomy

marker.eq-citationThe numbered chip link.
card.eq-citation-cardA hint popover anchored to the marker.
card source.eq-citation-card-source
card title.eq-citation-card-title
card snippet.eq-citation-card-snippet
list.eq-citation-list
list heading.eq-citation-list-heading
list items.eq-citation-list-itemsThe ol.
list item.eq-citation-list-item
list index.eq-citation-list-index
list link.eq-citation-list-link
list title.eq-citation-list-title
list source.eq-citation-list-source

Keyboard

TabFocuses a marker and shows its card.
EnterOpens the source.
EscapeHides the card while the marker keeps focus.

Notes

  • Each marker is a real link named "Source 1: title", and the card's text is its description, so the snippet is heard on focus.
  • The card opens on hover and on keyboard focus, not on touch, where a tap simply follows the link.
  • The pointer can move from the marker into the card without it closing.
  • Markers are 16px tall so they never push lines apart, with a 24px hit area around them.
  • The card needs the Popover API and CSS anchor positioning. Where either is missing the marker carries a title attribute with the title, source and snippet instead; where popover="hint" is missing it behaves as a manual popover.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/citation.tsx171 lines
"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type ComponentProps,
  type CSSProperties,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import "./citation.css";

export type CitationProps = Omit<ComponentProps<"a">, "title" | "href"> & {
  /** The number shown, matching the entry in CitationList. */
  index: number;
  href: string;
  /** Title of the source document or page. */
  title: string;
  /** Site or document name, e.g. "status.example.com". */
  source?: string;
  /** The passage the claim rests on. */
  snippet?: string;
};

/** How long the card waits before closing, so the pointer can cross from the marker into it. */
const CLOSE_DELAY = 120;

export function Citation({
  index,
  href,
  title,
  source,
  snippet,
  className,
  style,
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  onKeyDown,
  ...rest
}: CitationProps) {
  const id = useId();
  const cardId = `${id}-card`;
  const anchor = `--eq-citation-${id.replace(/[^a-zA-Z0-9]/g, "")}`;
  const cardRef = useRef<HTMLSpanElement>(null);
  const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  // The server renders the title attribute; the card replaces it once the browser can place it.
  const [enhanced, setEnhanced] = useState(false);

  useEffect(() => {
    const canPop = typeof HTMLElement.prototype.showPopover === "function";
    setEnhanced(canPop && CSS.supports("anchor-name", "--a"));
    return () => clearTimeout(closeTimer.current);
  }, []);

  const open = () => {
    clearTimeout(closeTimer.current);
    const card = cardRef.current;
    if (enhanced && card && !card.matches(":popover-open")) card.showPopover();
  };

  const close = (now = false) => {
    clearTimeout(closeTimer.current);
    const hide = () => {
      const card = cardRef.current;
      if (card?.matches(":popover-open")) card.hidePopover();
    };
    if (now) hide();
    else closeTimer.current = setTimeout(hide, CLOSE_DELAY);
  };

  const fallbackTitle = [title, source, snippet].filter(Boolean).join("\n");

  return (
    <>
      <a
        {...rest}
        href={href}
        className={["eq-citation", className].filter(Boolean).join(" ")}
        style={{ "--_anchor": anchor, ...style } as CSSProperties}
        aria-label={`Source ${index}: ${title}`}
        aria-describedby={snippet || source ? cardId : undefined}
        title={enhanced ? undefined : fallbackTitle}
        onPointerEnter={(event) => {
          onPointerEnter?.(event);
          if (event.pointerType !== "touch") open();
        }}
        onPointerLeave={(event) => {
          onPointerLeave?.(event);
          close();
        }}
        onFocus={(event) => {
          onFocus?.(event);
          open();
        }}
        onBlur={(event) => {
          onBlur?.(event);
          close(true);
        }}
        onKeyDown={(event: KeyboardEvent<HTMLAnchorElement>) => {
          onKeyDown?.(event);
          if (event.key === "Escape" && cardRef.current?.matches(":popover-open")) {
            event.preventDefault();
            event.stopPropagation();
            close(true);
          }
        }}
      >
        {index}
      </a>
      <span
        ref={cardRef}
        id={cardId}
        popover="hint"
        className="eq-citation-card"
        style={{ "--_anchor": anchor } as CSSProperties}
        onPointerEnter={open}
        onPointerLeave={() => close()}
      >
        {source && <span className="eq-citation-card-source">{source}</span>}
        <span className="eq-citation-card-title">{title}</span>
        {snippet && <span className="eq-citation-card-snippet">{snippet}</span>}
      </span>
    </>
  );
}

export type CitationListProps = ComponentProps<"div"> & {
  /** Heading above the list. */
  heading?: ReactNode;
};

export function CitationList({ heading = "Sources", className, children, ...rest }: CitationListProps) {
  const headingId = useId();
  return (
    <div {...rest} className={["eq-citation-list", className].filter(Boolean).join(" ")}>
      <p className="eq-citation-list-heading" id={headingId}>
        {heading}
      </p>
      {/* role="list" keeps list semantics in Safari once the markers are removed. */}
      <ol role="list" aria-labelledby={headingId} className="eq-citation-list-items">
        {children}
      </ol>
    </div>
  );
}

export type CitationListItemProps = Omit<ComponentProps<"li">, "title" | "value"> & {
  /** Matches the index of the Citation markers that point here. */
  index: number;
  href: string;
  title: string;
  source?: string;
};

export function CitationListItem({ index, href, title, source, className, ...rest }: CitationListItemProps) {
  return (
    <li {...rest} value={index} className={["eq-citation-list-item", className].filter(Boolean).join(" ")}>
      <span className="eq-citation-list-index">
        {index}
      </span>
      <a className="eq-citation-list-link" href={href}>
        <span className="eq-citation-list-title">{title}</span>
        {source && <span className="eq-citation-list-source">{source}</span>}
      </a>
    </li>
  );
}
components/equip/citation.css203 lines
@layer equip {
  /* The marker: a small numbered chip that sits on the text line without pushing it apart. */
  .eq-citation {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 16px;
    height: 16px;
    margin-inline: 2px;
    padding-inline: var(--eq-space-1);
    vertical-align: text-top;
    border-radius: var(--eq-radius-s);
    background: var(--eq-tint-strong);
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
    font-variant-numeric: tabular-nums;
    text-decoration: none;
    anchor-name: var(--_anchor);
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  /* Visible at 16px, hit target at 24px. */
  .eq-citation::after {
    content: "";
    position: absolute;
    inset: -4px;
  }

  @media (hover: hover) {
    .eq-citation:hover {
      background: var(--eq-solid);
      color: var(--eq-solid-ink);
    }
  }

  .eq-citation:active {
    scale: var(--eq-press);
  }

  .eq-citation:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-citation-card {
    display: none;
    position: fixed;
    position-anchor: var(--_anchor);
    position-area: block-end span-inline-end;
    position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
    inset: auto;
    box-sizing: border-box;
    width: max-content;
    max-width: min(20rem, calc(100vw - 2 * var(--eq-space-4)));
    margin: var(--eq-space-1) 0;
    padding: var(--eq-space-4);
    overflow: visible;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-l);
    background: var(--eq-raised);
    box-shadow: var(--eq-shadow);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.45 var(--eq-font);
    text-align: start;
    transform-origin: top left;
    opacity: 0;
    scale: 0.96;
    translate: 0 4px;
    /* Leaves faster than it arrives. */
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur) var(--eq-ease),
      display var(--eq-dur) allow-discrete,
      overlay var(--eq-dur) allow-discrete;
    transition-behavior: allow-discrete;
  }

  .eq-citation-card:popover-open {
    display: flex;
    flex-direction: column;
    gap: 2px;
    opacity: 1;
    scale: 1;
    translate: 0;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring),
      translate var(--eq-dur-move) var(--eq-spring),
      display var(--eq-dur-move) allow-discrete,
      overlay var(--eq-dur-move) allow-discrete;
    transition-behavior: allow-discrete;
  }

  @starting-style {
    .eq-citation-card:popover-open {
      opacity: 0;
      scale: 0.96;
      translate: 0 4px;
    }
  }

  .eq-citation-card-source {
    color: var(--eq-ink-2);
    font-size: var(--eq-text-xs);
  }

  .eq-citation-card-title {
    font-weight: var(--eq-weight-strong);
  }

  .eq-citation-card-snippet {
    margin-block-start: var(--eq-space-1);
    padding-inline-start: var(--eq-space-2);
    border-inline-start: 2px solid var(--eq-line-strong);
    color: var(--eq-ink-2);
  }

  .eq-citation-list {
    min-width: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  .eq-citation-list-heading {
    margin: 0 0 var(--eq-space-1);
    color: var(--eq-ink-2);
    font-size: var(--eq-text-xs);
    font-weight: var(--eq-weight-strong);
  }

  .eq-citation-list-items {
    display: flex;
    flex-direction: column;
    gap: 2px;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .eq-citation-list-item {
    display: flex;
    align-items: center;
    gap: var(--eq-space-2);
    min-width: 0;
    min-height: var(--eq-h-s);
  }

  .eq-citation-list-index {
    flex: none;
    display: inline-grid;
    place-items: center;
    min-width: 16px;
    height: 16px;
    padding-inline: var(--eq-space-1);
    box-sizing: border-box;
    border-radius: var(--eq-radius-s);
    background: var(--eq-tint-strong);
    font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
    font-variant-numeric: tabular-nums;
  }

  .eq-citation-list-link {
    display: inline-flex;
    align-items: baseline;
    gap: var(--eq-space-2);
    min-width: 0;
    color: var(--eq-ink);
    text-decoration: none;
    border-radius: var(--eq-radius-s);
  }

  @media (hover: hover) {
    .eq-citation-list-link:hover .eq-citation-list-title {
      text-decoration: underline;
      text-decoration-thickness: 1px;
      text-underline-offset: 0.2em;
    }
  }

  .eq-citation-list-link:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-citation-list-title {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .eq-citation-list-source {
    flex: none;
    color: var(--eq-ink-3);
    font-size: var(--eq-text-xs);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index