AI Citation
An inline source marker for a generated answer: a superscript pill carrying a
label such as 1 or a, and a preview card holding the source's host, title and
an optional excerpt.
The card is not a popover and not a modal. It opens on hover and on focus, and
closes on Escape, which means it is reachable by keyboard and works on touch
without a portal. The pill itself is a real <a> that opens the source in a new
tab, so the citation is useful even when the card is closed.
Usage
Drop it straight into assistant prose — anywhere a sentence runs. Pair it with
AI Response, which turns [n] markers into pills
automatically when you want a lighter citation.
<template>
<div class="w-full max-w-md rounded-2xl border border-default p-6 text-sm leading-relaxed text-highlighted">
<p>
Animation primitives usually animate
<MAiCitation
label="1"
title="Spring defaults"
url="https://example.com"
description="The easing and stiffness values that every component in this kit falls back to when you do not pass your own."
>
<template #favicon>
<UIcon name="i-lucide-sparkles" />
</template>
</MAiCitation>
on first paint and then never touch
<MAiCitation
label="2"
title="Interruptible by default"
url="https://motion.dev/docs/react-motion"
/>
again. The same rules hold for
<MAiCitation
label="3"
title="Reduced motion"
url="https://example.com/docs/accessibility"
description="Every primitive in this kit reads prefers-reduced-motion and falls back to an instant state change."
/>
.
</p>
<p class="mt-4 text-xs text-muted">
Hover or focus a pill to peek. Press Escape to dismiss.
</p>
</div>
</template>
<script setup lang="ts">
</script>
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | number | string | - | Required. What the pill reads. Numbers are usual; short strings work when a model cites by key rather than by index. |
title | string | - | Required. The source's headline, rendered in the card. |
url | string | - | Required. The pill's href, and the value aria-describedby is keyed on. The
host shown in the card is parsed from it with new URL, with a leading www.
stripped; an unparseable value is displayed verbatim rather than throwing. |
description | string | - | Optional excerpt. The block is omitted entirely when this is empty or absent. |
class | string | '' | Merged onto the wrapping <span>, which also carries
relative inline-block align-super. |
Slots
| Slot | Props | Description |
|---|---|---|
favicon | - | Rendered inside a 14px square at the top-left of the card. Anything that fits
an <img> works — a logo service URL, a repo isotype. The wrapper applies
size-full, object-cover and fill-current to slotted children, so an
SVG that ships fill="none" picks up the current text colour. With no slot,
a neutral i-lucide-globe stands in; the component never invents a badge from
the first letter of the host. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.aiCitation in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | relative inline-block align-super |
pill | - | inline-flex h-4 min-w-4 items-center justify-center rounded-full border border-default bg-muted px-1 text-[10px] font-medium text-muted no-underline transition-colors hover:border-accented hover:text-highlighted |
card | - | absolute bottom-full left-1/2 z-50 -ml-36 mb-1.5 block w-72 rounded-xl border border-default bg-elevated p-3 text-left align-baseline shadow-lg |
header | - | mb-1 flex items-center gap-1.5 |
host | - | truncate text-xs text-muted |
icon | - | ml-auto text-muted |
title | - | block text-sm leading-snug font-medium text-highlighted |
description | - | mt-1 block text-xs leading-relaxed text-muted |
Accessibility
- The pill is a real anchor with
target="_blank"andrel="noopener noreferrer", so the link is reachable, focusable and middle-clickable independently of the card. - The card is wired to the pill with
aria-describedby, and the attribute is present only while the card is mounted — the reference never dangles. - The wrapper opens on
focusand closes onblur, so tabbing through prose reveals each citation without a pointer.mouseleaveandblurshare one 120ms grace period, which is what lets the pointer cross the 6px gap between pill and card without the card dismissing first. - The Escape handler is registered only while the card is open, so a page full of closed citations never intercepts the key.
- Under
prefers-reduced-motion: reducethe card appears at its resting state withduration: 0, and its exit is an instant opacity change with no travel. - The card is a
<span>inside a<span>, positioned absolutely. It is not in a portal, so an ancestor withoverflow: hiddenwill clip it.
Performance
- Card open state is one
ref. Opening mounts a singleMotionelement; the:keyis the card id, so changingurlremounts rather than morphing a card that described a different page. - Centring uses a
-ml-36margin, nottranslateX(-50%), and the fixed width is written out instylealongside it. Motion animatesscaleandyon the same element, so a hand-writtentransformwould fight the animation for thetransformproperty. The trade-off is that the card width is fixed at 18rem in the stylesheet, so a very long title wraps rather than widening. - Only one
setTimeoutis ever live.openclears a pending close andonBeforeUnmountclears it again, so navigating away mid-grace cannot leave a timer holding a detached component.