Thumbnail List
alphav0.2.0Linked thumbnail list — small image, title, and meta line per row, each row one link target.
Context
Built for sidebars and dropdowns: related posts, popular articles, search-suggestion results, 'up next' media queues, file-picker recents. Short lists only (3–10 items typically; cap ~20). Same family rhythm as author-card / newsletter-signup — composes cleanly in the same sidebar. Polymorphic link component, custom meta render slot, frame toggle, empty state.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/thumbnail-listAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/thumbnail-list-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Related
Default chrome — framed card, default header icon, native anchors. Drop into any sidebar slot.
Demo source
"use client"; import type { ComponentProps, ElementType } from "react";import { Bookmark } from "lucide-react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { ThumbnailList } from "./thumbnail-list";import { THUMBNAIL_LIST_DUMMY, THUMBNAIL_LIST_DUMMY_DATED, THUMBNAIL_LIST_DUMMY_TR,} from "./dummy-data"; // Demo-only mock router-Link substitute. Real consumers pass NextLink /// RemixLink / TanStack Link / etc. — anything that accepts an href and// renders an anchor.function MockRouterLink({ href, children, ...rest}: ComponentProps<"a">) { return ( <a data-router-link="mock" href={href} onClick={(e) => { e.preventDefault(); // Real Link would pushState here. Demo just no-ops. }} {...rest} > {children} </a> );}const MockRouterLinkAs: ElementType = MockRouterLink; const RELATIVE_FORMATTER = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); function formatRelativeDays(dateStr: string): string { const days = Math.round( (Date.parse(dateStr) - Date.now()) / (1000 * 60 * 60 * 24) ); return RELATIVE_FORMATTER.format(days, "day");} export default function ThumbnailListDemo() { return ( <Tabs defaultValue="default" className="w-full"> <SwipeTabsList> <TabsTrigger value="default">Default</TabsTrigger> <TabsTrigger value="no-frame">No frame</TabsTrigger> <TabsTrigger value="custom-meta">Custom meta</TabsTrigger> <TabsTrigger value="no-icon">No icon + router link</TabsTrigger> <TabsTrigger value="empty">Empty state</TabsTrigger> <TabsTrigger value="i18n">Custom icon + Turkish</TabsTrigger> </SwipeTabsList> <TabsContent value="default" className="mt-6 max-w-md"> <ThumbnailList items={THUMBNAIL_LIST_DUMMY} /> <p className="mt-3 text-xs text-muted-foreground"> Default chrome — framed card, default header icon, native anchors. Drop into any sidebar slot. </p> </TabsContent> <TabsContent value="no-frame" className="mt-6 max-w-md"> <ThumbnailList items={THUMBNAIL_LIST_DUMMY} framed={false} /> <p className="mt-3 text-xs text-muted-foreground"> <code>framed={"{false}"}</code> drops the card chrome — useful when the parent already provides padding / background / borders, or when stacking multiple lists without nested cards. </p> </TabsContent> <TabsContent value="custom-meta" className="mt-6 max-w-md"> <ThumbnailList items={THUMBNAIL_LIST_DUMMY_DATED} labels={{ heading: "More from this author" }} renderMeta={(item) => { const dated = item as (typeof THUMBNAIL_LIST_DUMMY_DATED)[number]; return ( <time className="text-xs text-muted-foreground mt-1 block" dateTime={dated.publishedAt} > {formatRelativeDays(dated.publishedAt)} </time> ); }} /> <p className="mt-3 text-xs text-muted-foreground"> <code>renderMeta</code> takes over the secondary line — here a relative-time stamp. The slot replaces (rather than augments) the default meta, so you own its semantics + a11y. </p> </TabsContent> <TabsContent value="no-icon" className="mt-6 max-w-md"> <ThumbnailList items={THUMBNAIL_LIST_DUMMY} headerIcon={null} linkComponent={MockRouterLinkAs} labels={{ heading: "Trending now" }} /> <p className="mt-3 text-xs text-muted-foreground"> <code>headerIcon={"{null}"}</code> hides the icon entirely (use this when the heading is self-explanatory).{" "} <code>linkComponent={"{NextLink}"}</code> swaps the native{" "} <code><a></code> for your router's Link — preserves client-side nav, prefetching, etc. Inspect the rendered DOM to see the <code>data-router-link="mock"</code> attribute on each row. </p> </TabsContent> <TabsContent value="empty" className="mt-6 max-w-md"> <ThumbnailList items={[]} labels={{ heading: "Recently viewed", emptyText: "Nothing here yet — articles you read will show up here.", }} /> <p className="mt-3 text-xs text-muted-foreground"> When <code>items</code> is empty the component still renders the heading + a graceful empty-state line. Consumers don't need to guard against <code>items.length === 0</code> at the call site. </p> </TabsContent> <TabsContent value="i18n" className="mt-6 max-w-md"> <ThumbnailList items={THUMBNAIL_LIST_DUMMY_TR} headerIcon={Bookmark} labels={{ heading: "Kaydedilen Haberler" }} /> <p className="mt-3 text-xs text-muted-foreground"> Custom <code>headerIcon</code> (here Lucide's{" "} <code>Bookmark</code>) plus a localized heading — both the icon and the label are independently overridable. </p> </TabsContent> </Tabs> );} Usage
When to use
A linked thumbnail-list block — small image + title + meta line per row, each row a single link target. Built for sidebars and dropdowns: related posts, popular articles, search-suggestion results, "up next" media queues, file-picker recents.
Use it for short lists (3–10 items typically; cap at ~20). For longer result sets reach for data-table or magazine-layout.
Basic example
import { ThumbnailList } from "@/components/thumbnail-list"
const items = [
{ id: "1", title: "Sustainable cities", imageSrc: "/img/1.jpg", meta: "5 min read", href: "/news/1" },
{ id: "2", title: "Public transit on the rebound", imageSrc: "/img/2.jpg", meta: "3 min read", href: "/news/2" },
]
export function Example() {
return <ThumbnailList items={items} labels={{ heading: "Related" }} />
}No frame (inline)
Pass framed={false} to drop the card chrome. Useful in a search-suggestions dropdown, a modal, or any other surface that already provides its own framing.
<ThumbnailList items={searchHits} framed={false} />Custom meta rendering
The default meta render shows item.meta as a plain string. Pass renderMeta(item) to render dates, badges, scores, or anything else.
<ThumbnailList
items={posts}
renderMeta={(item) => (
<time className="text-xs text-muted-foreground mt-1 block" dateTime={item.publishedAt}>
{formatRelative(item.publishedAt)}
</time>
)}
/>Polymorphic link
Per-item href drives navigation. By default the row link renders as a native <a>. Pass linkComponent for router-aware links (e.g. next/link or RemixLink).
import Link from "next/link"
<ThumbnailList items={items} linkComponent={Link} />Empty state
When items is empty, the default empty fallback renders with labels.emptyText. For a richer custom UI, pass emptyState as a ReactNode — it replaces the default message.
<ThumbnailList
items={[]}
labels={{
heading: "Recently viewed",
emptyText: "Nothing here yet — articles you read will show up.",
}}
/>Notes
- Items render as
<li>inside a<ul>. The link wraps the row content; keyboard focus and hover both shift the title color (group-hover:text-primary/group-focus-visible:text-primary). - The header icon is decorative (
aria-hidden). PassheaderIcon={null}to hide it entirely. - Items without
hrefrender as plain rows (no link affordance). Useful when you want the visual but not the navigation. - Thumbnails default to
w-20 h-16(5:4 landscape). Override viaimageClassNamefor square / portrait / other shapes. - The component is exported as
React.memo. Pass stable refs forlinkComponent/headerIcon/renderMetafor memoization to hold. - Don't reach for this for paginated or virtualized lists — it renders all items eagerly. Cap input at ~20.
Features
- Fixed-shape items (id / title / imageSrc / imageAlt? / meta? / href?)
- Polymorphic link via linkComponent slot (default native anchor)
- renderMeta slot for dates / badges / scores / custom meta UI
- Frame toggle (framed: true card-style, false borderless inline)
- Custom or default header icon (Lucide); pass null to hide
- Empty state: emptyState ReactNode OR labels.emptyText fallback
- Configurable heading level (h2 / h3 / h4)
- i18n via labels prop with English defaults
- Memoized; SSR-safe; lazy-loaded thumbnails
- Keyboard parity (focus-visible:text-primary mirrors hover)