Skip to content
ilinxa/pro-ui

Thumbnail List

alphav0.2.0

Linked thumbnail list — small image, title, and meta line per row, each row one link target.

Category: Data DisplayUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/thumbnail-list

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/thumbnail-list-fixtures

CLI 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

Demo source

demo.tsxtsx
"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>&lt;a&gt;</code> for your router&apos;s Link — preserves          client-side nav, prefetching, etc. Inspect the rendered DOM to see          the <code>data-router-link=&quot;mock&quot;</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&apos;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&apos;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). Pass headerIcon={null} to hide it entirely.
  • Items without href render 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 via imageClassName for square / portrait / other shapes.
  • The component is exported as React.memo. Pass stable refs for linkComponent / headerIcon / renderMeta for 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)

Tags

thumbnail-listlistthumbnailsrelatedsidebardata

Dependencies

npm peer deps: lucide-react@^1.11.0