Skip to content

Citations ​

Numbered citation chips with hover cards, linked to a sources list that lights up the cited source.

Searched the web · 4 sources

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

  1. 1Why Is the Sky Blue?spaceplace.nasa.gov
  2. 2Rayleigh scatteringen.wikipedia.org
  3. 3Rayleigh scattering | Definition & Factsbritannica.com
  4. 4Why is the sky blue? Optical effects explainedmetoffice.gov.uk

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/citations.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/citations/ (plus lib/utils.ts if you don't have it):

vue
<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>
vue
<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>
vue
<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>
ts
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 ''
  }
}
ts
export { default as CitationChip } from './CitationChip.vue'
export { default as Citations } from './Citations.vue'
export type { CitationSource } from './context'

Usage ​

ts
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):

vue
<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:

vue
<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 ​

PropTypeDefaultDescription
sourcesCitationSource[]—Sources, numbered from 1.
textstring''Text with [1]-style markers. Ignored when the default slot is used.
hideSourcesbooleanfalseDon't render the sources list.
sourcesLabelstring'Sources'Heading of the sources list.

CitationChip ​

PropTypeDefaultDescription
nnumber—1-based number of the source in the parent's sources.

CitationSource ​

FieldTypeDescription
titlestringTitle of the page.
urlstring?Link; also used to derive the domain.
snippetstring?Excerpt shown in the hover card.
faviconstring?Favicon URL. Falls back to the domain's initial on a tinted square.
domainstring?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.

Designed by Kuldeep Pisda · Released under the MIT License