Citations
Numbered citation chips with hover cards, linked to a sources list that lights up the cited source.
The sky looks blue because sunlight scatters off the molecules in the air, and short wavelengths scatter far more than long ones 1. This Rayleigh scattering grows with the inverse fourth power of the wavelength, so blue light is scattered several times more strongly than red 23. At sunrise and sunset the light crosses much more atmosphere, most of the blue is scattered out along the way, and the reds and oranges are what reach you 4.
Sources
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/citations.jsonManual installation
Install the dependencies:
npm install motion-vThen copy these files into components/ui/citations/ (plus lib/utils.ts if you don't have it):
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { CitationSource } from './context'
import { motion } from 'motion-v'
import { computed, provide, ref, toRef, useId } from 'vue'
import { cn } from '@/lib/utils'
import CitationChip from './CitationChip.vue'
import { CITATIONS_KEY, sourceDomain } from './context'
import SourceIcon from './SourceIcon.vue'
const props = withDefaults(defineProps<{
sources: CitationSource[]
/** Text with `[1]`-style markers. Ignored when the default slot is used. */
text?: string
/** Don't render the sources list. */
hideSources?: boolean
/** Heading of the sources list. */
sourcesLabel?: string
class?: HTMLAttributes['class']
}>(), {
text: '',
hideSources: false,
sourcesLabel: 'Sources',
})
const uid = `vx-cite-${useId()}`
const active = ref<number | null>(null)
provide(CITATIONS_KEY, { sources: toRef(props, 'sources'), active, uid })
// "Prices rose 4% [1][3]." → text, chip 1, chip 3, text
const parts = computed(() =>
props.text.split(/(\[\d+\])/).filter(Boolean).map((part) => {
const m = /^\[(\d+)\]$/.exec(part)
return m && Number(m[1]) <= props.sources.length ? { n: Number(m[1]) } : { text: part }
}),
)
const spring = { type: 'spring', stiffness: 500, damping: 38 } as const
</script>
<template>
<div :class="cn('flex flex-col gap-4', props.class)">
<div class="text-sm leading-7 text-foreground/90">
<slot>
<p class="m-0">
<template v-for="(part, i) in parts" :key="i">
<CitationChip v-if="'n' in part" :n="part.n!" />
<template v-else>
{{ part.text }}
</template>
</template>
</p>
</slot>
</div>
<section v-if="!props.hideSources && props.sources.length" :aria-labelledby="`${uid}-heading`">
<h4 :id="`${uid}-heading`" class="m-0 mb-1.5 text-xs font-medium text-muted-foreground">
{{ props.sourcesLabel }}
</h4>
<ol class="m-0 grid list-none gap-1 p-0 sm:grid-cols-2" @pointerleave="active = null">
<li v-for="(source, i) in props.sources" :key="i" class="relative min-w-0">
<motion.span
v-if="active === i + 1"
:layout-id="`${uid}-highlight`"
aria-hidden="true"
class="absolute inset-0 rounded-lg bg-muted ring-1 ring-border"
:transition="spring"
/>
<component
:is="source.url ? 'a' : 'div'"
:href="source.url"
:target="source.url ? '_blank' : undefined"
:rel="source.url ? 'noopener noreferrer' : undefined"
class="relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-foreground no-underline outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
@pointerenter="active = i + 1"
@focus="active = i + 1"
@blur="active = null"
>
<span class="w-3 shrink-0 text-center text-[11px] font-semibold tabular-nums text-muted-foreground">{{ i + 1 }}</span>
<SourceIcon :source="source" :n="i + 1" />
<span class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-[13px] font-medium leading-5 text-foreground">{{ source.title }}</span>
<span class="truncate text-xs leading-4 text-muted-foreground">{{ sourceDomain(source) }}</span>
</span>
</component>
</li>
</ol>
</section>
</div>
</template><script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AnimatePresence, motion } from 'motion-v'
import { computed, inject, onBeforeUnmount, ref, watch } from 'vue'
import { cn } from '@/lib/utils'
import { CITATIONS_KEY, sourceDomain } from './context'
import SourceIcon from './SourceIcon.vue'
const props = defineProps<{
/** 1-based number of the source in the parent `<Citations>`' `sources`. */
n: number
class?: HTMLAttributes['class']
}>()
// The chip and its teleported card are two roots: forward extra attributes to the chip.
defineOptions({ inheritAttrs: false })
const ctx = inject(CITATIONS_KEY, null)
const source = computed(() => ctx?.sources.value[props.n - 1])
const domain = computed(() => (source.value ? sourceDomain(source.value) : ''))
const cardId = computed(() => `${ctx?.uid ?? 'vx-cite'}-card-${props.n}`)
const el = ref<HTMLElement>()
const open = ref(false)
const pos = ref<{ left: number, top?: number, bottom?: number, above: boolean }>({ left: 0, above: true })
let timer: ReturnType<typeof setTimeout> | undefined
const CARD_WIDTH = 288
function place() {
if (!el.value)
return
const r = el.value.getBoundingClientRect()
const left = Math.min(Math.max(8, r.left + r.width / 2 - CARD_WIDTH / 2), window.innerWidth - CARD_WIDTH - 8)
const above = r.top > 200
pos.value = above
? { left, bottom: window.innerHeight - r.top + 8, above }
: { left, top: r.bottom + 8, above }
}
function show(delay = 90) {
clearTimeout(timer)
if (ctx)
ctx.active.value = props.n
timer = setTimeout(() => {
place()
open.value = true
}, delay)
}
function hide(delay = 120) {
clearTimeout(timer)
timer = setTimeout(() => {
open.value = false
if (ctx?.active.value === props.n)
ctx.active.value = null
}, delay)
}
const close = () => hide(0)
watch(open, (isOpen) => {
if (typeof window === 'undefined')
return
if (isOpen)
window.addEventListener('scroll', close, { passive: true, capture: true })
else
window.removeEventListener('scroll', close, { capture: true })
})
onBeforeUnmount(() => {
clearTimeout(timer)
if (typeof window !== 'undefined')
window.removeEventListener('scroll', close, { capture: true })
})
const highlighted = computed(() => open.value || ctx?.active.value === props.n)
</script>
<template>
<component
:is="source?.url ? 'a' : 'button'"
v-bind="$attrs"
ref="el"
:href="source?.url"
:target="source?.url ? '_blank' : undefined"
:rel="source?.url ? 'noopener noreferrer' : undefined"
:type="source?.url ? undefined : 'button'"
:aria-label="source ? `Source ${props.n}: ${source.title}` : `Source ${props.n}`"
:aria-describedby="open ? cardId : undefined"
:class="cn(
'relative -top-px mx-0.5 inline-flex h-[1.4em] min-w-[1.4em] items-center justify-center rounded-md px-1 align-middle text-[0.7rem] font-semibold tabular-nums leading-none no-underline ring-1 outline-none transition-[color,background-color,box-shadow,translate] duration-200 focus-visible:ring-[3px] focus-visible:ring-ring/50',
highlighted
? '-translate-y-px bg-brand/15 ring-brand/50'
: 'bg-muted ring-border',
props.class,
)"
@pointerenter="show()"
@pointerleave="hide()"
@focus="show(0)"
@blur="hide(0)"
@keydown.esc="close"
>
<!-- colour on an inner span so global `a:hover` styles can't override it -->
<span :class="highlighted ? 'text-foreground' : 'text-muted-foreground'">{{ props.n }}</span>
</component>
<Teleport to="body">
<AnimatePresence>
<motion.div
v-if="open && source"
:id="cardId"
key="card"
role="tooltip"
class="fixed z-[100] flex w-72 flex-col gap-1.5 rounded-xl border bg-popover p-3.5 text-left text-popover-foreground shadow-xl"
:style="{ left: `${pos.left}px`, top: pos.top !== undefined ? `${pos.top}px` : undefined, bottom: pos.bottom !== undefined ? `${pos.bottom}px` : undefined }"
:initial="{ opacity: 0, y: pos.above ? 6 : -6, scale: 0.97, filter: 'blur(4px)' }"
:animate="{ opacity: 1, y: 0, scale: 1, filter: 'blur(0px)' }"
:exit="{ opacity: 0, y: pos.above ? 4 : -4, scale: 0.98, filter: 'blur(2px)', transition: { duration: 0.12 } }"
:transition="{ type: 'spring', stiffness: 520, damping: 32, filter: { duration: 0.2, ease: 'easeOut' } }"
@pointerenter="show(0)"
@pointerleave="hide()"
>
<span class="flex items-center gap-2 text-xs text-muted-foreground">
<SourceIcon :source="source" :n="props.n" />
<span class="min-w-0 flex-1 truncate">{{ domain }}</span>
<span class="rounded bg-muted px-1.5 py-px text-[10px] font-semibold tabular-nums">{{ props.n }}</span>
</span>
<span class="line-clamp-2 text-sm font-medium leading-snug">{{ source.title }}</span>
<span v-if="source.snippet" class="line-clamp-3 text-xs leading-5 text-muted-foreground">{{ source.snippet }}</span>
</motion.div>
</AnimatePresence>
</Teleport>
</template><script setup lang="ts">
import type { CitationSource } from './context'
import { computed, ref } from 'vue'
import { cn } from '@/lib/utils'
import { sourceDomain } from './context'
const props = defineProps<{
source: CitationSource
/** 1-based index, picks the fallback colour. */
n: number
}>()
const broken = ref(false)
const letter = computed(() => (sourceDomain(props.source) || props.source.title).charAt(0).toUpperCase())
const tones = ['bg-chart-1', 'bg-chart-2', 'bg-chart-3', 'bg-chart-4', 'bg-chart-5']
</script>
<template>
<img
v-if="props.source.favicon && !broken"
:src="props.source.favicon"
alt=""
class="size-4 shrink-0 rounded-sm"
@error="broken = true"
>
<span
v-else
aria-hidden="true"
:class="cn('flex size-4 shrink-0 items-center justify-center rounded-sm text-[9px] font-bold leading-none text-background', tones[(props.n - 1) % tones.length])"
>{{ letter }}</span>
</template>import type { InjectionKey, Ref } from 'vue'
export interface CitationSource {
title: string
url?: string
/** A short excerpt shown in the hover card. */
snippet?: string
/** Favicon URL. Falls back to the domain's first letter. */
favicon?: string
/** Display domain. Derived from `url` when omitted. */
domain?: string
}
export interface CitationsContext {
sources: Ref<CitationSource[]>
/** 1-based number of the citation currently hovered / focused, in chips or the list. */
active: Ref<number | null>
uid: string
}
export const CITATIONS_KEY: InjectionKey<CitationsContext> = Symbol('vx-citations')
export function sourceDomain(source: CitationSource) {
if (source.domain)
return source.domain
try {
return new URL(source.url ?? '').hostname.replace(/^www\./, '')
}
catch {
return ''
}
}export { default as CitationChip } from './CitationChip.vue'
export { default as Citations } from './Citations.vue'
export type { CitationSource } from './context'Usage
import { CitationChip, Citations } from '@/components/ui/citations'Pass text with [n] markers and they become chips (markers past the end of sources stay plain text):
<Citations :sources="sources" text="Blue light scatters more strongly than red [1][2]." />Or write your own markup with <CitationChip> anywhere inside, e.g. in a rendered Markdown answer:
<Citations :sources="sources">
<p>Blue light scatters more strongly <CitationChip :n="1" /> than red.</p>
</Citations>Hovering or focusing a chip opens a card with the source's domain, title and snippet, and highlights it in the sources list. Hovering a source highlights its chips. Chips with a url are links that open the source in a new tab.
API
Citations
| Prop | Type | Default | Description |
|---|---|---|---|
sources | CitationSource[] | — | Sources, numbered from 1. |
text | string | '' | Text with [1]-style markers. Ignored when the default slot is used. |
hideSources | boolean | false | Don't render the sources list. |
sourcesLabel | string | 'Sources' | Heading of the sources list. |
CitationChip
| Prop | Type | Default | Description |
|---|---|---|---|
n | number | — | 1-based number of the source in the parent's sources. |
CitationSource
| Field | Type | Description |
|---|---|---|
title | string | Title of the page. |
url | string? | Link; also used to derive the domain. |
snippet | string? | Excerpt shown in the hover card. |
favicon | string? | Favicon URL. Falls back to the domain's initial on a tinted square. |
domain | string? | Display domain, if it shouldn't come from url. |
Chips are real links or buttons with an aria-label like “Source 2: Rayleigh scattering”; the card is a role="tooltip" referenced by aria-describedby and closes on Escape.