134 / Wayfinding
Link Preview
An inline link that shows where it leads before you follow it: an image, the title, a description, and the domain.
Long pages need a way to see where you are. The Floating Index keeps the outline close without taking a column, and a Shift Button at the end tells people what comes next.
Rest the pointer on a link, or tab to it. The first preview loads, the second is already known. On a touch screen the links simply open.
"use client"
import {
LinkPreview,
type LinkPreviewData,
} from "mischief-ui/link-preview"
const pages: Record<string, LinkPreviewData> = {
"/docs/components/floating-index": {
title: "Floating Index",
description:
"A compact outline for long pages. It keeps the active section and reading progress visible without becoming another permanent sidebar.",
image: "/demo/gallery/floating-index.png",
},
"/docs/components/shift-button": {
title: "Shift Button",
description:
"A call to action that trades its leading icon for a directional cue when someone approaches it.",
image: "/demo/gallery/shift-button.png",
},
}
async function loadPreview(href: string) {
await new Promise((resolve) => setTimeout(resolve, 700))
const page = pages[href]
if (!page) throw new Error(`No preview for ${href}`)
return page
}
export function LinkPreviewDemo() {
return (
<div className="w-full max-w-[34rem] text-sm leading-relaxed">
<p>
Long pages need a way to see where you are. The{" "}
<LinkPreview
href="/docs/components/floating-index"
loadPreview={loadPreview}
>
Floating Index
</LinkPreview>{" "}
keeps the outline close without taking a column, and a{" "}
<LinkPreview
href="/docs/components/shift-button"
preview={pages["/docs/components/shift-button"]}
>
Shift Button
</LinkPreview>{" "}
at the end tells people what comes next.
</p>
<p className="text-muted-foreground mt-3 text-xs">
Rest the pointer on a link, or tab to it. The first preview loads, the
second is already known. On a touch screen the links simply open.
</p>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/link-previewimport { LinkPreview } from "mischief-ui/link-preview"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { createPortal } from "react-dom" import { cn } from "@/lib/utils" export type LinkPreviewData = { title: string description?: string /** An image or screenshot of the page. */ image?: string /** Defaults to the link's host name. */ domain?: stringUsage
async function loadPreview(href: string) {
const response = await fetch(`/api/unfurl?url=${encodeURIComponent(href)}`)
if (!response.ok) throw new Error("No preview")
return (await response.json()) as LinkPreviewData
}
export function Note() {
return (
<p>
The{" "}
<LinkPreview href="https://developer.mozilla.org/docs/Web/CSS/@media/prefers-reduced-motion" loadPreview={loadPreview}>
reduced motion query
</LinkPreview>{" "}
is worth reading first.
</p>
)
}Where the preview comes from
Pass preview when you already have the data. Pass loadPreview to fetch it the first time someone rests on the link. Loading starts as soon as the pointer arrives, so it usually finishes during the open delay, and a skeleton holds the card's shape when it does not. Results are kept per href for the life of the page and shared by every link to the same address. A rejected load is not kept, so the next hover tries again, and the card meanwhile says there is no preview and still shows the domain.
Fetching another site's page from the browser is usually blocked, so loadPreview normally calls your own server, which reads the page's Open Graph tags.
Hover, focus, and touch
- A pointer has to rest on the link for openDelay, 500ms by default, so a pointer crossing the paragraph opens nothing.
- Leaving waits closeDelay, 250ms by default, and the card sits flush against the link, so the pointer can move onto the card without it closing.
- Keyboard focus opens it after the same delay. Escape and moving focus away close it.
- On touch screens it never opens. A tap follows the link at once, and a long press is left to the browser, which already offers its own preview and menu there.
API
hrefstringWhere the link goes.previewLinkPreviewDataPreview data the host already has.loadPreview(href: string) => Promise<LinkPreviewData>Fetches the preview on first hover or focus. Results are kept per href.openDelaynumberMilliseconds of rest before the card opens. Defaults to 500.closeDelaynumberMilliseconds the card waits for the pointer to come back. Defaults to 250.loadingLabel, unavailableLabelstringWhat the card says while loading and when there is nothing to show.childrenReactNodeThe link text.classNamestringClasses for the link....anchorProps (LinkPreviewProps)AnchorHTMLAttributesNative link attributes such as target and rel.LinkPreviewData
titlestringThe page's title.descriptionstringA line or two about the page. Clamped to three lines.imagestringAn image or screenshot URL. The card leaves it out if it fails to load.domainstringShown under the text. Defaults to the link's host name.Accessibility
The link is a native a element and nothing about it changes: its click, its focus, and its keyboard behavior are the browser's. The card is supplementary. It has role tooltip, is attached to the link with aria-describedby only while it is open, and holds nothing interactive, so it never becomes a stop in the tab order. Escape closes it. The image is decorative because the title is printed beside it, and the loading state is named for screen readers. With reduced motion the card appears without its short fade and rise, and the skeleton does not pulse.