---
title: Archive
description: A filterable grid with categories, subcategories and tags, that leaves routing to you.
source: https://adamjurek.com/components/archive
---

## Home


The component museum is this, listing the registry. It knows nothing about
registries.

<!-- ::start:showcase demo="archive" height="520" -->
<!-- ::end:showcase -->

## Three kinds of grouping, on purpose

| | Filterable | Why |
| --- | --- | --- |
| Category | yes | Every item is in exactly one. An item that plausibly fits two means the categories are wrong |
| Subcategory | no | Free text, for reading. An unrecognised one costs nothing |
| Tags | yes | Cross-cutting. A tag used once gets a chip used once, which is the signal it should not have been a tag |

Categories are a partition and tags are a set. Collapsing them into one concept
is the usual mistake and it produces a filter row where "Motion" and "no-deps"
sit side by side as though they were the same kind of thing.

## The schema earns its place

`parseArchive` does one thing Zod cannot express: it checks that every item's
category was declared.

That is the failure worth catching, because it is invisible. An item pointing
at a category nobody declared is filtered out of every view, renders nowhere,
and produces no error at all - it simply is not in the list, and the list looks
fine.

## Where this is used

| Where | Listing |
| --- | --- |
| `/components` | every registry item, filtered by the `category` search param |
| `apps/web/src/routes/components/index.tsx` | maps the registry onto this component's shape |

The mapping is a few lines and deliberately not shared. The registry knows
about files and dependency versions; the archive knows about a title, a group
and a picture. Keeping them apart is what lets this list things that are not
packages.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/archive.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/archive.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.1 |
| Category | layout · Containers |
| Files | `archive.tsx`, `archive.schemas.ts`, `pagination.tsx` |
| Dependencies | `zod@^4.4.3` |
| Also installs | `pagination`, `icon` |
| Tags | block, grid, filter, schema |


## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { Archive } from "@sushindustries/ui";

const categories = [{ id: "guides", label: "Guides" }];

const items = [
	{
		id: "getting-started",
		title: "Getting started",
		description: "The first page to read.",
		category: "guides",
		tags: ["setup"],
		dependencies: [],
		href: "/guides/getting-started",
	},
];

export function Example() {
	return (
		<Archive
			categories={categories}
			items={items}
			hrefForCategory={(id) => `/guides?category=${id}`}
			renderLink={({ href, className, children }) => (
				<a href={href} className={className}>
					{children}
				</a>
			)}
		/>
	);
}
```

## What you should see

An "All" chip plus one chip per category, each showing a count, above a
grid of cards. With one item and no `previewSrc`, the card shows its
category label where the preview would sit, then the title, then a
"No dependencies" row - the empty state is rendered on purpose, not left
blank.

## If nothing happens

An empty grid with the chips still visible means the filters matched
nothing - check `active` against a category `id` you actually declared,
not its label. No tag row appearing is not a bug: it only renders when
`hrefForTag` is passed, regardless of whether any item carries tags.


## Guides


## Routing stays yours

`renderLink` gets `kind` and `id` alongside a plain `href`:

```tsx
renderLink={({ kind, id, className, children }) =>
	kind === "item" ? (
		<Link to="/components/$slug" params={{ slug: id }} className={className}>
			{children}
		</Link>
	) : (
		<Link to="/components" search={{ category: id }} className={className}>
			{children}
		</Link>
	)
}
```

> [!CAUTION] A typed router needs the pattern, not the path
> This is why `kind` and `id` exist rather than just `href`. Handing TanStack
> Router's `Link` an already-resolved `/components/reveal` produces an anchor
> with the right href whose click is intercepted and then silently fails to
> match `/components/$slug` - so every card looks like a link and does
> nothing. Seven of ten cards did exactly that before the callback carried the
> parts instead of the result. `href` stays for hosts that just want an anchor.

## Cards are the same shape regardless of content

Previews are 16:9 and clipped, centred in their frame. Without that, a grid of
ten components is ten screenshots of different sizes rather than a set, and the
eye reads the variation as meaning something.

```css
.archive-preview {
	aspect-ratio: 16 / 9;
	display: grid;
	place-items: center;
	overflow: hidden;
}
```

`previewSrc` is optional, because not everything is visual: a frontmatter
parser has nothing to show, and a card that insists on a picture would invent a
meaningless one. Items without it get their `preview` sentence instead, which
is also what a screen reader gets for the ones that do.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `categories` | `readonly ArchiveCategory[]` | - | The chips, in the order given. One with no items still gets a chip, counted zero. |
| `items` | `readonly ArchiveItem[]` | - | Everything, before filtering. Category counts come from here, so they hold steady as filters narrow. |
| `active?` | `string` | `"all"` | Current category filter id, or `"all"`. |
| `activeTag?` | `string` | - | Current tag filter, if any. Narrows within the active category. |
| `hrefForCategory` | `(id: string) => string` | - | Builds the href for a filter chip. The route owns routing, not this. |
| `hrefForTag?` | `(tag: string \| undefined) => string` | - | Builds the href for a tag chip. Pass `undefined` to clear the tag. |
| `page?` | `number` | - | 1-based page within the filtered result. Absent means "no pagination". |
| `pageSize?` | `number` | `24` | Items per page when `page` is set. |
| `hrefForPage?` | `(page: number) => string` | - | Builds the href for a page number. Required when `page` is set. |
| `renderPageLink?` | `PaginationProps["renderLink"]` | - | Rendered around every page number, forwarded to `Pagination` untouched. Without it page links are plain anchors, which a client-side router does not intercept - every page click becomes a full document load. |
| `renderLink` | `(props: { kind: "category" \| "tag" \| "item"; id: string; href: string; className: string; "data-tone"?: string; children: ReactNode; }) => ReactNode` | - | Renders the link wrapper, so the host can use its router's Link. `kind` and `id` are passed alongside the plain href because a typed router needs the route pattern and its params, not a path that has already been resolved - handing `Link` a resolved `/components/reveal` gets an anchor with the right href whose click is intercepted and then silently fails to match `/components/$slug`. The href stays for hosts that just want an anchor. |
| `emptyLabel?` | `string` | `"Nothing here yet."` | Replaces the grid when the filters match nothing. The chips stay, so the reader can undo. |

<!-- /generated:api -->

## Notes

`page`, `pageSize` and `hrefForPage` are one feature, not three - pagination
is on only when `page` is set, and `hrefForPage` is required at that point
because there is no page number without a link to reach it. Leave `page`
unset for an unpaginated grid; `pageSize` and `hrefForPage` are then ignored.

`hrefForTag` works the same way for the tag row: absent, no tag chips render
at all, regardless of whether `items` carry tags. A grid that cannot link to
a tag has nothing useful to say about one.

`renderLink` is called for three different `kind`s - `category`, `tag`,
`item` - with the same shape each time. A host that only handles one kind
correctly will find the others silently rendering plain anchors or nothing,
since `renderLink` is the only thing standing between a chip and a route.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="archive" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { useSearch } from "@tanstack/react-router";
import { Archive } from "@sushindustries/ui";
import { categories, items } from "./guides.catalogue";

export function GuidesPage() {
	const { category, tag } = useSearch({ from: "/guides" });

	return (
		<main className="container section">
			<Archive
				categories={categories}
				items={items}
				active={category}
				activeTag={tag}
				hrefForCategory={(id) => `/guides?category=${id}`}
				hrefForTag={(value) =>
					value
						? `/guides?category=${category}&tag=${value}`
						: `/guides?category=${category}`
				}
				renderLink={({ href, className, children }) => (
					<a href={href} className={className}>
						{children}
					</a>
				)}
			/>
		</main>
	);
}
```

## What this example is not

The plain-anchor `renderLink` above works, but every click is a full
document load - a typed router's own `Link` replaces it in production,
the way Guides shows. This example also skips pagination: `page`,
`pageSize` and `hrefForPage` are only worth adding once a category holds
more items than fit comfortably on one screen.
