引用与来源
New引用与来源组件族:行内引用角标(悬停弹出来源预览浮层)、来源卡片,以及答案尾部带级联进场的来源列表。
Design tokens act as the single source of truth for a product's visual language, typically encoding color, spacing, and typography as named values that both designers and code can reference. Because tokens are platform-agnostic, a single edit can propagate to web, iOS, and Android without touching component code directly.
Sources
TSXcomponents/previews/blocks/citations.preview.tsx
"use client";
import { BookOpen, FileText, Globe } from "lucide-react";
import {
CitationChip,
type CitationSource,
SourceCard,
SourceList,
} from "@/components/motion/citations";
const SOURCES: CitationSource[] = [
{
title: "Design Tokens: The Deep Dive",
domain: "spec.design-tokens.dev",
favicon: <Globe className="h-4 w-4" />,
snippet:
"Tokens are the smallest named values in a design system — color, spacing, and type — shared across every platform that renders the product.",
href: "https://spec.design-tokens.dev",
},
{
title: "Naming Conventions for Token Systems",
domain: "docs.tokens-studio.io",
favicon: <BookOpen className="h-4 w-4" />,
snippet:
"A consistent tier naming scheme (core, semantic, component) keeps large token sets legible as they grow across teams and brands.",
href: "https://docs.tokens-studio.io",
},
{
title: "Cross-Platform Token Pipelines",
domain: "guide.tokenize.dev",
favicon: <FileText className="h-4 w-4" />,
snippet:
"Build pipelines transform a single token source into platform-native formats, so one edit propagates to web, iOS, and Android alike.",
href: "https://guide.tokenize.dev",
},
];
/**
* Answer paragraph with three inline `CitationChip`s, each previewing one of
* `SOURCES` on hover/focus, followed by a `SourceList` summarizing the same
* three sources as cards — the two ends of the same citation set.
*/
export function CitationsPreview() {
return (
<div className="flex w-full items-center justify-center rounded-xl border border-border bg-neutral-100 p-8 dark:bg-neutral-900">
<div className="w-full max-w-xl">
<p className="text-sm leading-[22px]">
Design tokens act as the single source of truth for a product's visual language
<CitationChip index={1} source={SOURCES[0]} />, typically encoding color, spacing, and
typography as named values that both designers and code can reference
<CitationChip index={2} source={SOURCES[1]} />. Because tokens are platform-agnostic, a
single edit can propagate to web, iOS, and Android without touching component code
directly
<CitationChip index={3} source={SOURCES[2]} />.
</p>
<div className="mt-4">
<SourceList title="Sources">
{SOURCES.map((source, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: SOURCES is a static, never-reordered list.
<SourceCard key={index} source={source} index={index + 1} />
))}
</SourceList>
</div>
</div>
</div>
);
}
TSXcomponents/motion/citations/index.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/citations
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Children, type ReactNode, useCallback, useRef, useState } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface CitationSource {
title: ReactNode;
domain: ReactNode;
favicon?: ReactNode;
snippet?: ReactNode;
href?: string;
}
const CHIP_CLASSNAME =
"inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-black/[0.07] px-1 text-[10px] font-medium text-muted-foreground align-super dark:bg-white/10";
const OPEN_DELAY_MS = 150;
const CLOSE_DELAY_MS = 200;
export interface CitationChipProps {
index: number;
source?: CitationSource;
className?: string;
}
/**
* Small circular `[n]` badge dropped inline after a claim in body copy. With
* no `source` it's a static, non-interactive pill (`vertical-align: super`
* via `align-super`, standing in for a real `<sup>` without the cramped
* default line-height). With a `source`, it becomes a button that reveals a
* preview popover on hover (150ms open delay, 200ms close delay) or focus —
* a single shared timeout ref means moving the pointer from the chip into
* the popover itself cancels the pending close rather than dismissing it.
*/
export function CitationChip({ index, source, className }: CitationChipProps) {
const reduce = useReducedMotion() ?? false;
const [open, setOpen] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
const scheduleOpen = useCallback(() => {
clearTimer();
timeoutRef.current = setTimeout(() => setOpen(true), OPEN_DELAY_MS);
}, [clearTimer]);
const scheduleClose = useCallback(() => {
clearTimer();
timeoutRef.current = setTimeout(() => setOpen(false), CLOSE_DELAY_MS);
}, [clearTimer]);
const openNow = useCallback(() => {
clearTimer();
setOpen(true);
}, [clearTimer]);
if (!source) {
return <span className={cn(CHIP_CLASSNAME, className)}>{index}</span>;
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: hover/focus preview trigger wrapping the real button below; the span itself holds no semantics.
<span
className="relative inline-block"
onMouseEnter={scheduleOpen}
onMouseLeave={scheduleClose}
onFocus={openNow}
onBlur={scheduleClose}
>
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open}
className={cn(
CHIP_CLASSNAME,
"transition-colors hover:bg-[#339CFF] hover:text-white",
className,
)}
>
{index}
</button>
<AnimatePresence>
{open ? (
<motion.span
role="tooltip"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 4 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 4 }}
transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}
style={{
transformOrigin: "bottom center",
boxShadow:
"0 0 0 0.5px var(--citation-hairline), 0 3px 7.5px rgba(0,0,0,0.04), 0 0 20px rgba(0,0,0,0.05)",
}}
className={cn(
// Span-based throughout (styled block via classes): the chip is
// designed to sit inside <p> prose, where a <div> descendant is
// invalid HTML and would break hydration.
"absolute bottom-[calc(100%+6px)] left-1/2 z-30 block w-64 -translate-x-1/2 rounded-xl bg-white/95 p-3 text-left normal-case backdrop-blur-xl",
"[--citation-hairline:rgba(0,0,0,0.08)] dark:bg-neutral-800/95 dark:[--citation-hairline:rgba(255,255,255,0.15)]",
)}
>
<span className="flex items-center gap-1.5">
{source.favicon ? (
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{source.favicon}
</span>
) : null}
<span className="truncate text-muted-foreground text-xs">{source.domain}</span>
</span>
<span className="mt-1 line-clamp-2 font-medium text-[13px]">
{source.href ? (
<a href={source.href} target="_blank" rel="noreferrer" className="hover:underline">
{source.title}
</a>
) : (
source.title
)}
</span>
{source.snippet ? (
<span className="mt-1 line-clamp-2 text-muted-foreground text-xs">{source.snippet}</span>
) : null}
</motion.span>
) : null}
</AnimatePresence>
</span>
);
}
export interface SourceCardProps {
source: CitationSource;
index?: number;
className?: string;
}
/**
* One row in a `SourceList` — a favicon slot, then domain (with an optional
* static index badge floated to the right, styled like `CitationChip` but
* inert) over the title over a two-line snippet. The whole card links out
* when `source.href` is set.
*/
export function SourceCard({ source, index, className }: SourceCardProps) {
const rootClassName = cn(
"flex gap-3 rounded-xl p-3 transition-colors hover:bg-black/[0.03] dark:hover:bg-white/5",
className,
);
const content = (
<>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-black/5 dark:bg-white/10">
{source.favicon}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-muted-foreground text-xs">{source.domain}</span>
{index !== undefined ? (
<span className="ml-auto inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-black/[0.07] px-1 text-[10px] font-medium text-muted-foreground dark:bg-white/10">
{index}
</span>
) : null}
</div>
<div className="truncate font-medium text-sm">{source.title}</div>
{source.snippet ? (
<div className="line-clamp-2 text-[13px] text-muted-foreground">{source.snippet}</div>
) : null}
</div>
</>
);
if (source.href) {
return (
<a href={source.href} target="_blank" rel="noreferrer" className={rootClassName}>
{content}
</a>
);
}
return <div className={rootClassName}>{content}</div>;
}
export interface SourceListProps {
title?: ReactNode;
children?: ReactNode;
className?: string;
}
/**
* Vertical stack of `SourceCard`s under an optional muted title row (e.g.
* "Sources · 3"). Children fade/slide in together with a per-card stagger
* (`delay: index * 0.05`); `useReducedMotion()` renders the list directly
* with no entrance animation.
*/
export function SourceList({ title, children, className }: SourceListProps) {
const reduce = useReducedMotion() ?? false;
return (
<div className={cn("flex flex-col gap-1", className)}>
{title ? (
<div className="px-3 pb-1 font-medium text-muted-foreground text-xs">{title}</div>
) : null}
{reduce
? children
: Children.map(children, (child, index) => (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT, delay: index * 0.05 }}
>
{child}
</motion.div>
))}
</div>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/citations
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/citations/index.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/citations
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Children, type ReactNode, useCallback, useRef, useState } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface CitationSource {
title: ReactNode;
domain: ReactNode;
favicon?: ReactNode;
snippet?: ReactNode;
href?: string;
}
const CHIP_CLASSNAME =
"inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-black/[0.07] px-1 text-[10px] font-medium text-muted-foreground align-super dark:bg-white/10";
const OPEN_DELAY_MS = 150;
const CLOSE_DELAY_MS = 200;
export interface CitationChipProps {
index: number;
source?: CitationSource;
className?: string;
}
/**
* Small circular `[n]` badge dropped inline after a claim in body copy. With
* no `source` it's a static, non-interactive pill (`vertical-align: super`
* via `align-super`, standing in for a real `<sup>` without the cramped
* default line-height). With a `source`, it becomes a button that reveals a
* preview popover on hover (150ms open delay, 200ms close delay) or focus —
* a single shared timeout ref means moving the pointer from the chip into
* the popover itself cancels the pending close rather than dismissing it.
*/
export function CitationChip({ index, source, className }: CitationChipProps) {
const reduce = useReducedMotion() ?? false;
const [open, setOpen] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
const scheduleOpen = useCallback(() => {
clearTimer();
timeoutRef.current = setTimeout(() => setOpen(true), OPEN_DELAY_MS);
}, [clearTimer]);
const scheduleClose = useCallback(() => {
clearTimer();
timeoutRef.current = setTimeout(() => setOpen(false), CLOSE_DELAY_MS);
}, [clearTimer]);
const openNow = useCallback(() => {
clearTimer();
setOpen(true);
}, [clearTimer]);
if (!source) {
return <span className={cn(CHIP_CLASSNAME, className)}>{index}</span>;
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: hover/focus preview trigger wrapping the real button below; the span itself holds no semantics.
<span
className="relative inline-block"
onMouseEnter={scheduleOpen}
onMouseLeave={scheduleClose}
onFocus={openNow}
onBlur={scheduleClose}
>
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open}
className={cn(
CHIP_CLASSNAME,
"transition-colors hover:bg-[#339CFF] hover:text-white",
className,
)}
>
{index}
</button>
<AnimatePresence>
{open ? (
<motion.span
role="tooltip"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 4 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 4 }}
transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}
style={{
transformOrigin: "bottom center",
boxShadow:
"0 0 0 0.5px var(--citation-hairline), 0 3px 7.5px rgba(0,0,0,0.04), 0 0 20px rgba(0,0,0,0.05)",
}}
className={cn(
// Span-based throughout (styled block via classes): the chip is
// designed to sit inside <p> prose, where a <div> descendant is
// invalid HTML and would break hydration.
"absolute bottom-[calc(100%+6px)] left-1/2 z-30 block w-64 -translate-x-1/2 rounded-xl bg-white/95 p-3 text-left normal-case backdrop-blur-xl",
"[--citation-hairline:rgba(0,0,0,0.08)] dark:bg-neutral-800/95 dark:[--citation-hairline:rgba(255,255,255,0.15)]",
)}
>
<span className="flex items-center gap-1.5">
{source.favicon ? (
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{source.favicon}
</span>
) : null}
<span className="truncate text-muted-foreground text-xs">{source.domain}</span>
</span>
<span className="mt-1 line-clamp-2 font-medium text-[13px]">
{source.href ? (
<a href={source.href} target="_blank" rel="noreferrer" className="hover:underline">
{source.title}
</a>
) : (
source.title
)}
</span>
{source.snippet ? (
<span className="mt-1 line-clamp-2 text-muted-foreground text-xs">{source.snippet}</span>
) : null}
</motion.span>
) : null}
</AnimatePresence>
</span>
);
}
export interface SourceCardProps {
source: CitationSource;
index?: number;
className?: string;
}
/**
* One row in a `SourceList` — a favicon slot, then domain (with an optional
* static index badge floated to the right, styled like `CitationChip` but
* inert) over the title over a two-line snippet. The whole card links out
* when `source.href` is set.
*/
export function SourceCard({ source, index, className }: SourceCardProps) {
const rootClassName = cn(
"flex gap-3 rounded-xl p-3 transition-colors hover:bg-black/[0.03] dark:hover:bg-white/5",
className,
);
const content = (
<>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-black/5 dark:bg-white/10">
{source.favicon}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-muted-foreground text-xs">{source.domain}</span>
{index !== undefined ? (
<span className="ml-auto inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-black/[0.07] px-1 text-[10px] font-medium text-muted-foreground dark:bg-white/10">
{index}
</span>
) : null}
</div>
<div className="truncate font-medium text-sm">{source.title}</div>
{source.snippet ? (
<div className="line-clamp-2 text-[13px] text-muted-foreground">{source.snippet}</div>
) : null}
</div>
</>
);
if (source.href) {
return (
<a href={source.href} target="_blank" rel="noreferrer" className={rootClassName}>
{content}
</a>
);
}
return <div className={rootClassName}>{content}</div>;
}
export interface SourceListProps {
title?: ReactNode;
children?: ReactNode;
className?: string;
}
/**
* Vertical stack of `SourceCard`s under an optional muted title row (e.g.
* "Sources · 3"). Children fade/slide in together with a per-card stagger
* (`delay: index * 0.05`); `useReducedMotion()` renders the list directly
* with no entrance animation.
*/
export function SourceList({ title, children, className }: SourceListProps) {
const reduce = useReducedMotion() ?? false;
return (
<div className={cn("flex flex-col gap-1", className)}>
{title ? (
<div className="px-3 pb-1 font-medium text-muted-foreground text-xs">{title}</div>
) : null}
{reduce
? children
: Children.map(children, (child, index) => (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT, delay: index * 0.05 }}
>
{child}
</motion.div>
))}
</div>
);
}
API 参考
CitationChip
indexnumber—source?CitationSource—className?string—SourceCard
sourceCitationSource—index?number—className?string—SourceList
title?ReactNode—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.