# Sources

> Inline citation markers and a source list that light each other, with hover previews of the cited passage.

Category: AI. Docs: https://neonkit.heyaadi.com/components/sources

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/sources.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/sources`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

Builds on: [Hover Card](https://neonkit.heyaadi.com/components/hover-card.md), [Icons](https://neonkit.heyaadi.com/components/icons.md). The CLI installs them too.

## Usage

```tsx
import { Citation, SourceList, Sources } from "@/components/ui/sources"

<Sources sources={sources}>
  <p>Postgres stores timestamptz as UTC<Citation index={2} />.</p>
  <SourceList />
</Sources>
```

## Examples

### Cited answer

Hover or tab to a marker: it lights, its source lights in the list below, and a preview opens with the passage and the address. Hovering a source lights every marker that cites it.

```tsx
import { ChatMessage } from "@/components/ui/chat-message";
import { Citation, SourceList, Sources, type Source } from "@/components/ui/sources";

const SOURCES: Source[] = [
  {
    title: "Date and Time on the Internet: Timestamps",
    url: "https://www.rfc-editor.org/rfc/rfc3339#section-5.6",
    meta: "RFC 3339, § 5.6",
    date: "2002-07",
    snippet:
      "This document defines a date and time format for use in Internet protocols that is a profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar.",
  },
  {
    title: "8.5. Date/Time Types",
    url: "https://www.postgresql.org/docs/17/datatype-datetime.html",
    meta: "PostgreSQL 17",
    date: "2024-09-26",
    snippet:
      "For timestamp with time zone, the internally stored value is always in UTC. An input value that has an explicit time zone specified is converted to UTC using the appropriate offset for that time zone.",
  },
  {
    title: "Order timestamps come back an hour off in eu-central",
    url: "https://git.nightshift.dev/platform/orders-api/issues/2141",
    meta: "Issue #2141",
    date: "2020-03-18",
    snippet:
      "Columns of type timestamp come back as Dates built in the process's local time. On a host set to Europe/Berlin every value is an hour off from what psql shows.",
  },
  {
    title: "Date.prototype.toISOString()",
    url: "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString",
    meta: "MDN",
    date: "2025-07-10",
    snippet: "The timezone is always UTC, as denoted by the suffix Z.",
  },
  {
    title: "Don't Do This: timestamp (without time zone)",
    url: "https://wiki.postgresql.org/wiki/Don%27t_Do_This",
    meta: "PostgreSQL wiki",
    date: "2024-02-14",
    snippet:
      "Don't use the timestamp type to store timestamps, use timestamptz (also known as timestamp with time zone) instead.",
  },
];

const code = "rounded-[5px] bg-white/[0.07] px-[0.38em] py-[0.12em] font-mono text-[0.86em] text-white/90";

export default function SourcesDemo() {
  return (
    <div className="grid w-full max-w-2xl gap-8">
      <ChatMessage
        from="user"
        name="Ines Duarte"
        time="09:41"
        content="Our API returns created_at as 2026-10-17 09:30:00 with no zone and the mobile team keeps showing times an hour off. Should the column be timestamp or timestamptz, and what should go over the wire?"
      />
      <ChatMessage from="assistant" name="Argon" time="09:41" copyText="Store it as timestamptz and send RFC 3339 with an offset.">
        <Sources sources={SOURCES} className="grid gap-6">
          <div className="grid gap-3.5 text-[15px] leading-[1.65] text-white/[0.86]">
            <p>
              Make the column <code className={code}>timestamptz</code>. Postgres converts every input to UTC and stores
              that instant, so the value no longer depends on the zone of whoever wrote it
              <Citation index={2} />. The wiki is blunter: plain <code className={code}>timestamp</code> is for wall-clock
              times, never for moments
              <Citation index={5} />.
            </p>
            <p>
              On the wire, send RFC 3339 with an explicit offset, <code className={code}>2026-10-17T08:30:00Z</code>
              <Citation index={1} />
              . In the browser, <code className={code}>toISOString()</code> already produces that form
              <Citation index={4} />. The hour you&apos;re losing is already in your tracker: the driver parses zone-less
              columns in the server&apos;s local time
              <Citation index={3} />.
            </p>
          </div>
          <SourceList className="border-t border-white/[0.07] pt-4" />
        </Sources>
      </ChatMessage>
    </div>
  );
}
```

### Compact chips

variant="chips" keeps the list to one row of domains. Each chip previews its source on hover, and max folds the rest behind a +2 that moves focus to the first one it reveals.

```tsx
import { Citation, SourceList, Sources, type Source } from "@/components/ui/sources";

const SOURCES: Source[] = [
  {
    title: "Additional HTTP Status Codes",
    url: "https://www.rfc-editor.org/rfc/rfc6585#section-4",
    meta: "RFC 6585",
    date: "2012-04",
    snippet:
      "The 429 status code indicates that the user has sent too many requests in a given amount of time. The response representations SHOULD include details explaining the condition, and MAY include a Retry-After header.",
  },
  {
    title: "HTTP Semantics: Retry-After",
    url: "https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3",
    meta: "RFC 9110",
    date: "2022-06",
    snippet:
      "Servers send the Retry-After header field to indicate how long the user agent ought to wait before making a follow-up request. Its value is either an HTTP-date or a number of seconds to delay.",
  },
  {
    title: "Rate limits for the REST API",
    url: "https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api",
    date: "2025-05-02",
    snippet:
      "If you exceed a secondary rate limit, you will receive a 403 or 429 response. If the retry-after header is present, you should not retry your request until after that many seconds has elapsed.",
  },
  {
    title: "Exponential backoff and jitter",
    url: "https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/",
    date: "2015-03-04",
    snippet: "Adding jitter spreads the retries out, so a burst of clients that failed together does not retry together.",
  },
  {
    title: "octokit/plugin-throttling.js",
    url: "https://github.com/octokit/plugin-throttling.js",
    date: "2025-08-21",
    snippet: "Implements all recommended best practices to prevent hitting secondary rate limits.",
  },
];

export default function SourcesChips() {
  return (
    <Sources sources={SOURCES} className="grid w-full max-w-xl gap-4">
      <p className="text-[15px] leading-[1.65] text-white/[0.86]">
        Answer with 429 and a <code className="font-mono text-[0.86em] text-white/90">Retry-After</code> in seconds
        <Citation index={1} />
        <Citation index={2} />. Clients should wait at least that long, then back off with jitter so they don&apos;t all
        come back in the same second
        <Citation index={4} />. GitHub&apos;s API documents exactly this contract
        <Citation index={3} />.
      </p>
      <SourceList variant="chips" max={3} />
    </Sources>
  );
}
```

### Quoting the passage

quote replaces the source's snippet for one citation, so two markers on the same page can each show the sentence they rest on. Both markers for source 1 light together.

```tsx
import { Citation, Sources, type Source } from "@/components/ui/sources";

const SOURCES: Source[] = [
  {
    title: "CREATE INDEX",
    url: "https://www.postgresql.org/docs/17/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY",
    meta: "PostgreSQL 17",
    date: "2024-09-26",
    snippet: "Builds the index without taking any locks that prevent concurrent inserts, updates, or deletes on the table.",
  },
  {
    title: "Migration hangs forever on CREATE INDEX CONCURRENTLY",
    url: "https://git.nightshift.dev/platform/migrations/issues/14",
    meta: "Issue #14",
    date: "2022-07-25",
    snippet:
      "The migration sat at 0% for forty minutes. pg_stat_activity showed it waiting on a virtualxid lock held by an idle-in-transaction session from a REPL someone left open.",
  },
];

export default function SourcesQuotes() {
  return (
    <Sources sources={SOURCES} className="w-full max-w-xl">
      <p className="text-[15px] leading-[1.7] text-white/[0.86]">
        <code className="font-mono text-[0.86em] text-white/90">CONCURRENTLY</code> makes two passes over the table and,
        between them, waits for every transaction that could still see the old rows to finish
        <Citation
          index={1}
          quote="PostgreSQL must perform two scans of the table, and in addition it must wait for all existing transactions that could potentially modify or use the index to terminate."
        />
        . One forgotten <code className="font-mono text-[0.86em] text-white/90">idle in transaction</code> session is
        enough to stall it indefinitely
        <Citation index={2} />, and it can&apos;t run inside a transaction block at all
        <Citation
          index={1}
          quote="Another difference is that a regular CREATE INDEX command can be performed within a transaction block, but CREATE INDEX CONCURRENTLY cannot."
        />
        .
      </p>
    </Sources>
  );
}
```

### List on its own

SourceList takes sources directly when there are no inline markers.

```tsx
import { SourceList, type Source } from "@/components/ui/sources";

const SOURCES: Source[] = [
  {
    title: "Content Security Policy Level 3",
    url: "https://www.w3.org/TR/CSP3/#strict-dynamic-usage",
    meta: "W3C Working Draft",
    date: "2025-06-20",
    snippet:
      "'strict-dynamic' allows scripts loaded by an already trusted script to execute, and ignores allowlists such as 'self' or https: when it is present.",
  },
  {
    title: "Mitigate cross-site scripting (XSS) with a strict Content Security Policy",
    url: "https://web.dev/articles/strict-csp",
    meta: "web.dev",
    date: "2024-03-15",
    snippet: "A nonce-based strict CSP is the recommended approach for server-rendered pages; hash-based for static ones.",
  },
  {
    title: "CSP nonce is the same on every request to /pricing",
    url: "https://git.nightshift.dev/web/storefront/issues/388",
    meta: "Issue #388",
    date: "2022-12-05",
    snippet: "A per-request nonce needs dynamic rendering. Statically rendered pages get the nonce from build time.",
  },
];

export default function SourcesList() {
  return <SourceList sources={SOURCES} label="Sources for the CSP answer" className="w-full max-w-xl" />;
}
```

## API reference

### Sources

Holds the sources for every Citation and SourceList inside it and links their light. Renders a div.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `sources` | `Source[]` | — | In citation order: index 1 is the first. A Source is { title, url, domain?, snippet?, date?, meta? }. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### Source

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `string` | — | The page or document title. |
| `url` | `string` | — | Where the marker and list item link. http(s) links open in a new tab. |
| `domain` | `string` | `url host` | Shown instead of the host, without www. |
| `snippet` | `string` | — | A passage from the page. Two lines in the list, four in the preview. |
| `date` | `string` | — | ISO date, 2024-09-26, or 2002-07 for a month. Printed as Sep 26, 2024 or Jul 2002. |
| `meta` | `string` | — | A short qualifier after the domain: RFC 9110, Issue #2141, PostgreSQL 17. |

### Citation

An anchor. Other anchor attributes pass through.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `index` | `number` | — | 1-based position in the surrounding sources. |
| `source` | `Source` | — | The source, when the citation isn't inside Sources. |
| `quote` | `string` | — | The passage this claim rests on. Replaces the snippet in the preview. |
| `preview` | `boolean` | `true` | Open a preview card on hover and keyboard focus. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. |

### SourceList

An ordered list. Other list attributes pass through.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"list" \| "chips"` | `"list"` | list prints title, domain, date and snippet. chips prints the domain and previews the rest. |
| `sources` | `Source[]` | — | The sources, when the list isn't inside Sources. |
| `max` | `number` | — | Chips only: how many to show before a +n button. |
| `label` | `string` | `"Sources"` | Accessible name of the list. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. |

## Accessibility

- Each marker is a link named after its source, "Source 2: 8.5. Date/Time Types (postgresql.org)", so a screen reader reads more than a number.
- Tab to a marker to open its preview; the open card describes the marker through aria-describedby, and Escape closes it.
- Markers are inline text set directly after the word, so a number never starts a line on its own.
- The list is an <ol>, so its numbers match the markers for people who can't see the plates.
- Light is state only: a marker, its plate in the list and its row light while either is hovered or focused, and decay over 300ms.

## Source

`components/ui/sources.tsx`

```tsx
"use client";

import * as React from "react";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { ArrowUpRight } from "@/components/ui/icons";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

export interface Source {
  title: string;
  url: string;
  /** Shown instead of the url's host. */
  domain?: string;
  /** A passage from the page. The preview shows it under the title. */
  snippet?: string;
  /** ISO date: `2024-09-26`, or `2002-07` for a month. */
  date?: string;
  /** Short qualifier next to the domain, such as "Issue #2141" or "§ 5.6". */
  meta?: string;
}

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

/** `2024-09-26` → "Sep 26, 2024", `2002-07` → "Jul 2002". Parsed by hand so server and client agree. */
function formatDate(date?: string) {
  if (!date) return undefined;
  const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/.exec(date);
  if (!match) return date;
  const [, year, month, day] = match;
  if (!month) return year;
  const name = MONTHS[Number(month) - 1];
  if (!name) return year;
  return day ? `${name} ${Number(day)}, ${year}` : `${name} ${year}`;
}

function hostOf(url: string) {
  try {
    return new URL(url).hostname.replace(/^www\./, "");
  } catch {
    return url;
  }
}

/** Host and path without the scheme, for the preview's footer. */
function addressOf(url: string) {
  try {
    const { hostname, pathname, hash } = new URL(url);
    return `${hostname.replace(/^www\./, "")}${pathname === "/" ? "" : pathname}${hash}`;
  } catch {
    return url;
  }
}

function isExternal(url: string) {
  return /^https?:\/\//i.test(url);
}

const linkTarget = (url: string) => (isExternal(url) ? { target: "_blank", rel: "noopener noreferrer" } : {});

interface SourcesContextValue {
  sources: Source[];
  active: number | null;
  light: (index: number) => void;
  release: (index: number) => void;
}

const SourcesContext = React.createContext<SourcesContextValue | null>(null);

/** Lights `index` while `lit` holds, and lets go only if nothing else has taken over since. */
function useLight(context: SourcesContextValue | null, index: number, lit: boolean) {
  const light = context?.light;
  const release = context?.release;
  React.useEffect(() => {
    if (!light || !release || !lit) return;
    light(index);
    return () => release(index);
  }, [light, release, index, lit]);
}

export interface SourcesProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** The sources, in citation order. `<Citation index={1} />` is the first one. */
  sources: Source[];
  color?: NeonColor;
}

/** Holds the sources for the citations and lists inside it, and links their light. */
const Sources = React.forwardRef<HTMLDivElement, SourcesProps>(
  ({ sources, color, className, style, ...props }, ref) => {
    const [active, setActive] = React.useState<number | null>(null);
    const light = React.useCallback((index: number) => setActive(index), []);
    const release = React.useCallback(
      (index: number) => setActive((current) => (current === index ? null : current)),
      [],
    );
    const value = React.useMemo(() => ({ sources, active, light, release }), [sources, active, light, release]);

    return (
      <SourcesContext.Provider value={value}>
        <div
          ref={ref}
          data-active-source={active ?? undefined}
          style={neonVars(color, style)}
          className={cn("[--neon:var(--neon-accent,34_211_238)]", className)}
          {...props}
        />
      </SourcesContext.Provider>
    );
  },
);
Sources.displayName = "Sources";

// The index plate: unlit glass at rest; it strikes at once and decays over 300ms, like an LED.
const plate = cn(
  "inline-flex shrink-0 select-none items-center justify-center rounded-[5px] border font-mono leading-none tabular-nums",
  "border-white/[0.14] bg-white/[0.04] text-white/70",
  "transition-[color,background-color,border-color,box-shadow,text-shadow] duration-300 ease-out",
  "data-[lit]:border-[rgb(var(--neon)/0.75)] data-[lit]:bg-[rgb(var(--neon)/0.14)] data-[lit]:duration-75",
  "data-[lit]:text-[color-mix(in_srgb,rgb(var(--neon))_70%,white)]",
  "data-[lit]:shadow-[0_0_0_1px_rgb(var(--neon)/0.12),0_0_12px_rgb(var(--neon)/CALC(0.5*var(--neon-glow,1))),inset_0_0_6px_rgb(var(--neon)/CALC(0.25*var(--neon-glow,1)))]",
  "data-[lit]:[text-shadow:0_0_8px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1)))]",
);

function SourcePreview({ source, index, quote }: { source: Source; index: number; quote?: string }) {
  const passage = quote ?? source.snippet;
  const date = formatDate(source.date);
  return (
    <div className="grid gap-2.5">
      <div className="flex min-w-0 items-center gap-2 text-[12.5px] leading-5 text-white/55">
        <span data-lit="" className={cn(plate, "h-5 min-w-5 px-1 text-[11px]")}>
          {index}
        </span>
        <span className="min-w-0 max-w-[60%] shrink-0 truncate text-white/75">
          {source.domain ?? hostOf(source.url)}
        </span>
        {source.meta && (
          <>
            <span aria-hidden className="text-white/25">
              ·
            </span>
            <span className="min-w-0 truncate">{source.meta}</span>
          </>
        )}
        {date && (
          <time dateTime={source.date} className="ml-auto shrink-0 pl-2 tabular-nums">
            {date}
          </time>
        )}
      </div>
      <p className="text-[14px] font-medium leading-snug text-white/90">{source.title}</p>
      {passage && (
        <p className="line-clamp-4 border-l-2 border-[rgb(var(--neon)/0.45)] pl-3 text-[13px] leading-relaxed text-white/65">
          {passage}
        </p>
      )}
      <p className="flex min-w-0 items-center gap-1.5 pt-0.5 text-white/40">
        <span className="min-w-0 truncate font-mono text-[11.5px]">{addressOf(source.url)}</span>
        {isExternal(source.url) && <ArrowUpRight aria-hidden className="size-3.5 shrink-0" />}
      </p>
    </div>
  );
}

function sourceName(source: Source | undefined, index: number) {
  if (!source) return `Source ${index}`;
  return `Source ${index}: ${source.title} (${source.domain ?? hostOf(source.url)})`;
}

export interface CitationProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "color"> {
  /** 1-based position in the `sources` of the surrounding `<Sources>`. */
  index: number;
  /** The source, when the citation isn't inside `<Sources>`. */
  source?: Source;
  /** The passage this claim rests on. Replaces the source's snippet in the preview. */
  quote?: string;
  /** Show a preview card on hover and keyboard focus. */
  preview?: boolean;
  color?: NeonColor;
}

/** An inline marker that links to its source, lights it in the list and previews it. */
const Citation = React.forwardRef<HTMLAnchorElement, CitationProps>(
  (
    {
      index,
      source: sourceProp,
      quote,
      preview = true,
      color,
      className,
      style,
      onPointerEnter,
      onPointerLeave,
      onFocus,
      onBlur,
      ...props
    },
    ref,
  ) => {
    const context = React.useContext(SourcesContext);
    const source = sourceProp ?? context?.sources[index - 1];
    const [hot, setHot] = React.useState(false);
    const [open, setOpen] = React.useState(false);
    useLight(context, index, hot || open);
    const lit = hot || open || context?.active === index;

    // Props as an object so both renders below write the <a> out inline: shadcn rewrites `asChild` to Base UI's
    // `render={...}` in base-* projects, and drops it when the child is a variable.
    const markerProps: React.ComponentPropsWithoutRef<"a"> & { [data: `data-${string}`]: unknown } = {
      href: source?.url,
      ...(source ? linkTarget(source.url) : {}),
      "aria-label": sourceName(source, index),
      "data-lit": lit ? "" : undefined,
      "data-citation": index,
      style: neonVars(color, style),
      onPointerEnter: (event) => {
        onPointerEnter?.(event);
        if (event.pointerType === "mouse") setHot(true);
      },
      onPointerLeave: (event) => {
        onPointerLeave?.(event);
        setHot(false);
      },
      onFocus: (event) => {
        onFocus?.(event);
        setHot(true);
      },
      onBlur: (event) => {
        onBlur?.(event);
        setHot(false);
      },
      className: cn(
        "[--neon:var(--neon-accent,34_211_238)]",
        plate,
        // Inline, not inline-flex: an atomic box after a word is a break opportunity, inline text glued to it is not.
        "relative ml-[0.2em] mr-[0.06em] inline whitespace-nowrap px-[0.45em] py-[0.2em] align-[0.14em] text-[0.7em] no-underline outline-none",
        source ? "cursor-pointer hover:border-white/30 hover:text-white/90" : "cursor-default opacity-60",
        "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
        className,
      ),
      ...props,
    };

    if (!preview || !source)
      return (
        <a ref={ref} {...markerProps}>
          {index}
        </a>
      );

    return (
      <HoverCard openDelay={220} closeDelay={160} onOpenChange={setOpen}>
        <HoverCardTrigger asChild>
          <a ref={ref} {...markerProps}>
            {index}
          </a>
        </HoverCardTrigger>
        <HoverCardContent side="top" className="w-[min(21rem,calc(100vw-24px))]">
          <SourcePreview source={source} index={index} quote={quote} />
        </HoverCardContent>
      </HoverCard>
    );
  },
);
Citation.displayName = "Citation";

/** Keeps the arrow with the title's last word, so it never wraps onto a line alone. */
function TitleWithArrow({ title }: { title: string }) {
  const cut = title.lastIndexOf(" ");
  return (
    <>
      {cut > 0 ? `${title.slice(0, cut)} ` : ""}
      <span className="whitespace-nowrap">
        {title.slice(cut + 1)}
        <ArrowUpRight
          aria-hidden
          className="ml-1.5 inline-block size-3.5 align-[-1px] text-white/35 transition-[color,transform] duration-200 ease-out group-data-[lit]/source:-translate-y-px group-data-[lit]/source:translate-x-px group-data-[lit]/source:text-white/70"
        />
      </span>
    </>
  );
}

function ListItem({ source, index, context }: { source: Source; index: number; context: SourcesContextValue | null }) {
  const [hot, setHot] = React.useState(false);
  useLight(context, index, hot);
  const lit = hot || context?.active === index;
  const date = formatDate(source.date);

  return (
    <li>
      <a
        href={source.url}
        {...linkTarget(source.url)}
        data-lit={lit ? "" : undefined}
        onPointerEnter={(event) => event.pointerType === "mouse" && setHot(true)}
        onPointerLeave={() => setHot(false)}
        onFocus={() => setHot(true)}
        onBlur={() => setHot(false)}
        className={cn(
          "group/source -mx-3 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3.5 rounded-[10px] px-3 py-3 outline-none",
          "transition-[background-color] duration-300 ease-out data-[lit]:bg-white/[0.035] data-[lit]:duration-75",
          "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
        )}
      >
        <span data-lit={lit ? "" : undefined} className={cn(plate, "mt-px h-6 min-w-6 px-1 text-[12px]")}>
          {index}
        </span>
        <span className="grid min-w-0 gap-1">
          <span className="text-[14.5px] font-medium leading-snug text-white/90 transition-colors duration-150 group-data-[lit]/source:text-white">
            {isExternal(source.url) ? <TitleWithArrow title={source.title} /> : source.title}
          </span>
          {/* Each item carries its own dot; the clip hides the dot of whichever item starts a line. */}
          <span className="overflow-hidden text-[12.5px] leading-5 text-white/50">
            <span className="-ml-[15px] flex min-w-0 flex-wrap items-baseline">
              {[
                <span key="domain" className="text-white/65">
                  {source.domain ?? hostOf(source.url)}
                </span>,
                source.meta && <span key="meta">{source.meta}</span>,
                date && (
                  <time key="date" dateTime={source.date} className="tabular-nums">
                    {date}
                  </time>
                ),
              ]
                .filter(Boolean)
                .map((item, i) => (
                  <span key={i} className="relative whitespace-nowrap pl-[15px]">
                    <span aria-hidden className="absolute left-[6px] text-white/25">
                      ·
                    </span>
                    {item}
                  </span>
                ))}
            </span>
          </span>
          {source.snippet && (
            <span className="mt-0.5 line-clamp-2 text-[13.5px] leading-relaxed text-white/55">{source.snippet}</span>
          )}
        </span>
      </a>
    </li>
  );
}

function ChipItem({ source, index, context }: { source: Source; index: number; context: SourcesContextValue | null }) {
  const [hot, setHot] = React.useState(false);
  const [open, setOpen] = React.useState(false);
  useLight(context, index, hot || open);
  const lit = hot || open || context?.active === index;

  return (
    <li className="min-w-0 max-w-full">
      <HoverCard openDelay={260} closeDelay={160} onOpenChange={setOpen}>
        <HoverCardTrigger asChild>
          <a
            href={source.url}
            {...linkTarget(source.url)}
            // Starts with what the chip shows (number, domain, meta) so voice control can find it, then the title.
            aria-label={`Source ${index} ${source.domain ?? hostOf(source.url)}${source.meta ? ` · ${source.meta}` : ""}: ${source.title}`}
            data-lit={lit ? "" : undefined}
            onPointerEnter={(event) => event.pointerType === "mouse" && setHot(true)}
            onPointerLeave={() => setHot(false)}
            onFocus={() => setHot(true)}
            onBlur={() => setHot(false)}
            className={cn(
              "group/chip flex h-8 min-w-0 max-w-[20rem] items-center gap-2 rounded-lg border border-white/10 bg-white/[0.03] pl-1 pr-2.5 text-[13px] text-white/75 outline-none",
              "transition-[border-color,background-color,color] duration-300 ease-out",
              "data-[lit]:border-white/[0.18] data-[lit]:bg-white/[0.06] data-[lit]:text-white/95 data-[lit]:duration-75",
              "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
            )}
          >
            <span data-lit={lit ? "" : undefined} className={cn(plate, "h-6 min-w-6 px-1 text-[11.5px]")}>
              {index}
            </span>
            <span className="min-w-0 truncate">
              {source.domain ?? hostOf(source.url)}
              {source.meta && <span className="text-white/45"> · {source.meta}</span>}
            </span>
          </a>
        </HoverCardTrigger>
        <HoverCardContent side="top" align="start" className="w-[min(21rem,calc(100vw-24px))]">
          <SourcePreview source={source} index={index} />
        </HoverCardContent>
      </HoverCard>
    </li>
  );
}

export interface SourceListProps extends Omit<React.HTMLAttributes<HTMLOListElement>, "color"> {
  /** `list` shows title, domain, date and snippet; `chips` shows the domain and previews the rest on hover. */
  variant?: "list" | "chips";
  /** The sources, when the list isn't inside `<Sources>`. */
  sources?: Source[];
  /** Chips only: how many to show before a "+n" button. */
  max?: number;
  /** Accessible name of the list. */
  label?: string;
  color?: NeonColor;
}

const SourceList = React.forwardRef<HTMLOListElement, SourceListProps>(
  ({ variant = "list", sources: sourcesProp, max, label = "Sources", color, className, style, ...props }, ref) => {
    const context = React.useContext(SourcesContext);
    const sources = sourcesProp ?? context?.sources ?? [];
    const [expanded, setExpanded] = React.useState(false);
    const listId = React.useId();
    const moreRef = React.useRef<HTMLLIElement>(null);

    if (variant === "chips") {
      const limit = max !== undefined && max < sources.length && !expanded ? Math.max(0, max) : sources.length;
      const hidden = sources.length - limit;
      return (
        <ol
          ref={ref}
          id={listId}
          aria-label={label}
          style={neonVars(color, style)}
          className={cn("[--neon:var(--neon-accent,34_211_238)]", "flex flex-wrap items-center gap-1.5", className)}
          {...props}
        >
          {sources.slice(0, limit).map((source, i) => (
            <ChipItem key={`${i}-${source.url}`} source={source} index={i + 1} context={context} />
          ))}
          {hidden > 0 && (
            <li ref={moreRef}>
              <button
                type="button"
                aria-expanded={false}
                aria-controls={listId}
                aria-label={`Show ${hidden} more source${hidden === 1 ? "" : "s"}`}
                onClick={() => {
                  setExpanded(true);
                  // Focus the first chip that was hidden, so keyboard users land on new content.
                  requestAnimationFrame(() => {
                    const items = document.getElementById(listId)?.querySelectorAll<HTMLAnchorElement>("li > a");
                    items?.[limit]?.focus();
                  });
                }}
                className={cn(
                  "h-8 cursor-pointer rounded-lg px-2.5 text-[13px] tabular-nums text-white/55 outline-none",
                  "transition-[background-color,color] duration-150 hover:bg-white/[0.06] hover:text-white/90 active:scale-[0.97]",
                  "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
                )}
              >
                +{hidden}
              </button>
            </li>
          )}
        </ol>
      );
    }

    return (
      <ol
        ref={ref}
        aria-label={label}
        style={neonVars(color, style)}
        className={cn("[--neon:var(--neon-accent,34_211_238)]", "grid gap-0.5", className)}
        {...props}
      >
        {sources.map((source, i) => (
          <ListItem key={`${i}-${source.url}`} source={source} index={i + 1} context={context} />
        ))}
      </ol>
    );
  },
);
SourceList.displayName = "SourceList";

export { Sources, Citation, SourceList };
```
