ArchiveSearchFilter
Search and category/tag filters for archive pages.
On this page
ArchiveSearchFilter is the filter bar of an archive page: a search field plus one filter per taxonomy (categories, tags and post types), as dropdowns or chips. It holds the selection and reports every change through onFiltersChange; your code filters or re-fetches the listing. Type in the search field or pick a category below.
import { useState } from 'react'
import {
ArchiveSearchFilter,
EMPTY_ARCHIVE_FILTERS,
PostsList,
type ArchiveFilters,
type PostView,
} from '@systhemaui/next'
const categories = [
{ id: 'design', label: 'Design' },
{ id: 'engineering', label: 'Engineering' },
{ id: 'editorial', label: 'Editorial' },
]
function post(id: number, title: string, category: string, image: string): PostView {
const label = categories.find((c) => c.id === category)?.label ?? category
return {
id,
title,
label,
category: { name: label, slug: category },
href: '#',
tags: [],
postType: 'article',
author: { name: 'Mira Kovač' },
publishedAt: `2026-09-${10 + id}T09:00:00.000Z`,
media: { url: `/demo-assets/${image}`, alt: '', width: 1800, height: 1200 },
doc: null,
}
}
const posts = [
post(4, 'Designing with tokens from day one', 'design', 'mountains.webp'),
post(3, 'A calmer content model for editors', 'editorial', 'workspace.webp'),
post(2, 'Shipping a multilingual site in a week', 'engineering', 'city.webp'),
post(1, 'A field guide to spacing', 'design', 'coast.webp'),
]
export default function Demo() {
const [filters, setFilters] = useState<ArchiveFilters>(EMPTY_ARCHIVE_FILTERS)
const visible = posts.filter(
(p) =>
(!filters.categoryId || p.category?.slug === filters.categoryId) &&
p.title.toLowerCase().includes(filters.q.toLowerCase()),
)
return (
<div className="flex w-full flex-col gap-8">
<ArchiveSearchFilter
categories={categories}
onFiltersChange={setFilters}
syncUrl={false}
/>
<PostsList items={visible} view="grid4" />
</div>
)
}ImportLink to this section
import { ArchiveSearchFilter, EMPTY_ARCHIVE_FILTERS } from '@systhemaui/next'In a React app without Next.js, import them from @systhemaui/react.
How it worksLink to this section
- Each taxonomy with options (
categories,tags,types) becomes one filter; a taxonomy with no options is left out. Options are{ id, slug?, label }, and the selection stores theid, so you can query by relationship id. onFiltersChangereceives{ q, categoryId, tagId, postType }(EMPTY_ARCHIVE_FILTERSis the empty value). It fires on every change, but not on mount, so the server-rendered first page is not fetched twice.- Typing updates
qafter 300 ms; submitting the search form applies it at once. - Each active filter appears as a removable chip below the bar.
syncUrl(defaulttrue) mirrors the selection into the query string withhistory.replaceState(?q=…&category=<slug>&tag=<slug>&type=…), so the URL can be shared. It is a mirror only: it never triggers navigation or a server render. Passinitialto seed the filters from that query on the next visit.
Filter stylesLink to this section
filterStyle="dropdown" (default) renders one <select> per taxonomy. filterStyle="chips" renders each taxonomy as a row of toggle Chips; clicking the active chip clears it. Both drive the same state. filtersLabel adds a leading label.
import { ArchiveSearchFilter } from '@systhemaui/next'
export default function Demo() {
return (
<div className="w-full">
<ArchiveSearchFilter
filtersLabel="Filters"
filterStyle="chips"
categories={[
{ id: 1, slug: 'design', label: 'Design' },
{ id: 2, slug: 'engineering', label: 'Engineering' },
{ id: 3, slug: 'editorial', label: 'Editorial' },
]}
syncUrl={false}
/>
</div>
)
}ExamplesLink to this section
Order and labelsLink to this section
order reorders the taxonomies ('categories', 'tags', 'types'; default in that order). Taxonomies you leave out of a partial order follow in their default order. searchable={false} hides the search field, and labels translates the placeholders and the remove-chip names.
import { ArchiveSearchFilter } from '@systhemaui/next'
export default function Demo() {
return (
<div className="w-full">
<ArchiveSearchFilter
searchable={false}
order={['tags', 'categories']}
categories={[
{ id: 1, slug: 'design', label: 'Design' },
{ id: 2, slug: 'engineering', label: 'Engineering' },
]}
tags={[
{ id: 10, slug: 'tokens', label: 'Tokens' },
{ id: 11, slug: 'payload', label: 'Payload' },
]}
types={[
{ id: 'article', label: 'Article' },
{ id: 'video', label: 'Video' },
]}
labels={{ allCategories: 'All categories', allTags: 'All tags', allTypes: 'All types' }}
syncUrl={false}
/>
</div>
)
}Re-fetching from an APILink to this section
For a long archive, re-fetch the first page when the filters change and hand it to PostsList, which resets its paging when items changes. A Payload project has this wired up already: ArchiveFilterProvider, ArchiveFilterControl and PostsListClient from @systhemaui/payload/next, described in Archive pages.
'use client'
import { useEffect, useState } from 'react'
import {
ArchiveSearchFilter,
EMPTY_ARCHIVE_FILTERS,
PostsList,
type ArchiveFilterOption,
type PostView,
} from '@systhemaui/next'
export function Archive({
firstPage,
categories,
}: {
firstPage: PostView[]
categories: ArchiveFilterOption[]
}) {
const [filters, setFilters] = useState(EMPTY_ARCHIVE_FILTERS)
const [items, setItems] = useState(firstPage)
useEffect(() => {
if (filters === EMPTY_ARCHIVE_FILTERS) return
const query = new URLSearchParams({ q: filters.q, category: String(filters.categoryId ?? '') })
fetch(`/api/journal?${query}`)
.then((response) => response.json())
.then(setItems)
}, [filters])
return (
<>
<ArchiveSearchFilter categories={categories} onFiltersChange={setFilters} />
<PostsList items={items} view="grid3" />
</>
)
}PropsLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
categories | ArchiveFilterOption[] | - | Available category filter pills. |
tags | ArchiveFilterOption[] | - | Available tag filter pills. |
types | ArchiveFilterOption[] | - | Available post-type filter pills. |
order | readonly ArchiveFilterDimension[] | - | Order the taxonomy dropdowns render in. Reuses the showFilters vocabulary ('categories' | 'tags' | 'types'), so a consumer can e.g. put tags before categories with order={['tags', 'categories', 'types']}. Dimensions omitted from order fall back to the default order AFTER the listed ones; empty groups are still dropped. Defaults to category → tag → type. |
filterStyle | ArchiveFilterStyle | 'dropdown' | How the taxonomy filters render. 'dropdown' (default) keeps the <select> dropdowns; 'chips' renders each taxonomy as a wrap row of toggle <Chip>s (the podcasts wireframe). The free-text search input is unaffected. Same filter state/handlers drive both — purely presentational. |
searchable | boolean | true | Show the free-text search input. |
searchPlaceholder | string | 'Search…' | |
filtersLabel | string | - | Optional leading label for the filter bar (the Figma "FILTERS" eyebrow). Rendered before the search/dropdowns when set; omitted entirely otherwise. |
className | string | - | |
initial | Partial<ArchiveFilters> | - | Initial selection (server-rendered seed). |
onFiltersChange | ((filters: ArchiveFilters) => void) | - | The SOURCE OF TRUTH. Fired whenever the selection changes so the listing can re-fetch client-side. The consumer catch-all is statically cached and does not forward searchParams, so filtering is driven by this callback, NOT by the URL. |
syncUrl | boolean | true | When true (default), the control mirrors the active selection into the URL query string via history.replaceState purely for shareability. This is decorative — it never re-triggers a server render and is not relied on for filtering. Set false to opt out entirely. |
labels | { allCategories?, allTags?, allTypes?, removeFilter? } | - |
HTML and CSSLink to this section
The search field is a SearchField and the dropdowns use the FormSelect markup, so they share the form input tokens. The .archive-filter* hooks of the posts CSS layer lay them out: the fields share one wrapping row (each at least 16rem), and the active chips use the chip tokens.
<div class="archive-filter archive-filter-dropdown">
<div class="archive-filter-bar">
<form class="archive-filter-search" role="search">
<!-- SearchField markup, aria-label="Search…" -->
</form>
<div class="form-select-wrapper archive-filter-select archive-filter-select-category">
<select class="form-select" aria-label="Select a category">
<option value="">Select a category</option>
<option value="1">Design</option>
</select>
<span class="form-select-icon" aria-hidden="true"></span>
</div>
</div>
<div class="archive-filter-active">
<button type="button" class="archive-filter-chip" aria-label="Remove filter Design">
<span class="archive-filter-chip-label">Design</span>
<span class="archive-filter-chip-remove" aria-hidden="true">×</span>
</button>
</div>
</div>| Class | Styles |
|---|---|
posts-list | width: 100%;max-width: var(--container-width);margin-inline: auto; |
posts-list-grid | & { display: grid; grid-template-columns: minmax(0, 1fr); column-gap: var(--gap-sm, 1.25rem); row-gap: var(--gap-md, 2.5rem); }& > .post-card { animation-delay: var(--post-card-stagger-delay, 0ms); }& > * > .post-card { animation-delay: var(--post-card-stagger-delay, 0ms); } |
posts-list-row | display: flex;flex-direction: column;row-gap: var(--gap-md, 2.5rem); |
posts-list-grid-2 | @media (width >= 640px) { & { grid-template-columns: repeat(2, minmax(0, 1fr)); } }@media (width >= 640px) { & > :nth-child(2n - 1) { --post-card-stagger-delay: 0ms; } }@media (width >= 640px) { & > :nth-child(2n) { --post-card-stagger-delay: calc(var(--tw-stagger-delay, 120ms) * 1); } } |
posts-list-grid-3 | @media (width >= 640px) { & { grid-template-columns: repeat(2, minmax(0, 1fr)); } }@media (width >= 1192px) { & { grid-template-columns: repeat(3, minmax(0, 1fr)); } }@media (width >= 640px) and (width < 1192px) { & > :nth-child(2n - 1) { --post-card-stagger-delay: 0ms; } }@media (width >= 640px) and (width < 1192px) { & > :nth-child(2n) { --post-card-stagger-delay: calc(var(--tw-stagger-delay, 120ms) * 1); } }+ 3 more rules |
posts-list-grid-4 | @media (width >= 640px) { & { grid-template-columns: repeat(2, minmax(0, 1fr)); } }@media (width >= 1192px) { & { grid-template-columns: repeat(4, minmax(0, 1fr)); } }@media (width >= 640px) and (width < 1192px) { & > :nth-child(2n - 1) { --post-card-stagger-delay: 0ms; } }@media (width >= 640px) and (width < 1192px) { & > :nth-child(2n) { --post-card-stagger-delay: calc(var(--tw-stagger-delay, 120ms) * 1); } }+ 4 more rules |
posts-list-interspace | width: 100%; |
posts-list-empty | color: color-mix(in srgb, currentColor 60%, transparent);text-align: center;padding-block: var(--gap-md, 2.5rem); |
posts-list-actions | display: flex;align-items: center;justify-content: center;gap: var(--gap-sm, 1.25rem); |
posts-list-load-more | &:disabled, .posts-list-load-more[disabled] { opacity: 0.4; cursor: not-allowed; } |
posts-list-pagination | & { display: flex; justify-content: center; align-items: center; gap: var(--gallery-navigation-gap-x, 10px); }+ 13 more rules |
post-card | & { color: var(--color-typography-body); }.posts-list-grid > .post-card { animation-delay: var(--post-card-stagger-delay, 0ms); }.posts-list-grid > * > .post-card { animation-delay: var(--post-card-stagger-delay, 0ms); } |
post-card-label | & { color: color-mix(in srgb, currentColor 60%, transparent); }&.text-label { color: color-mix(in srgb, currentColor 60%, transparent); } |
post-highlight-label | & { color: color-mix(in srgb, currentColor 60%, transparent); }&.text-label { color: color-mix(in srgb, currentColor 60%, transparent); } |
post-type | color: color-mix(in srgb, currentColor 60%, transparent); |
text-label | & { font-family: var(--font-label-font-family); font-size: var(--typography-label-font-size); font-weight: var(--font-label-font-weight); letter-spacing: var(--typography-label-letter-spacing); line-height: var(--typography-label-line-height); text-transform: uppercase; text-decoration: none; font-style: var(--font-label-font-style); }.post-card-label.text-label { color: color-mix(in srgb, currentColor 60%, transparent); }+ 1 more rule |
post-card-title | text-wrap: balance; |
post-card-read-more | color: var(--color-link-text-default, currentColor);font-weight: 600;text-decoration-line: underline; |
post-highlight-read-more | color: var(--color-link-text-default, currentColor);font-weight: 600;text-decoration-line: underline; |
post-card-media-placeholder | width: 100%;height: 100%;background-color: color-mix(in srgb, currentColor 6%, transparent); |
post-card-row | @media (width >= 640px) { & .post-card-body { align-self: start; justify-content: flex-start; } } |
post-card-body | @media (width >= 640px) { .post-card-row .post-card-body { align-self: start; justify-content: flex-start; } } |
post-highlight | position: relative;overflow: hidden;border-radius: var(--card-default-border-radius, 12px); |
highlight-card | position: relative;overflow: hidden;border-radius: var(--card-default-border-radius, 12px); |
post-highlight-scrim | position: absolute;inset: 0;background: linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.35) 35%, rgba(0,0,0,0) 70%);pointer-events: none; |
post-highlight-overlay | position: relative;z-index: 1; |
post-highlight-excerpt | color: color-mix(in srgb, #ffffff 85%, transparent); |
post-meta | display: flex;align-items: center;flex-wrap: wrap;gap: 0.5em;color: color-mix(in srgb, currentColor 60%, transparent); |
post-byline-meta | display: flex;align-items: center;flex-wrap: wrap;gap: 0.5em;color: color-mix(in srgb, currentColor 60%, transparent); |
post-meta-avatar | width: var(--post-byline-avatar-size, 1.75em);height: var(--post-byline-avatar-size, 1.75em);border-radius: 9999px;object-fit: cover;flex-shrink: 0; |
post-meta-separator | opacity: 0.5; |
post-meta-author-role | & { opacity: 0.7; }&::before { content: ", "; } |
archive-filter | display: flex;flex-direction: column;gap: var(--gap-sm, 1.25rem); |
archive-filter-bar | display: flex;flex-wrap: wrap;align-items: center;gap: var(--gap-sm, 1.25rem); |
archive-filter-label | flex: 0 0 auto;color: color-mix(in srgb, currentColor 60%, transparent); |
archive-filter-search | flex: 1 1 16rem;min-width: 0;margin: 0; |
archive-filter-select | flex: 1 1 16rem;min-width: 0;margin: 0; |
archive-filter-chips | display: flex;flex-wrap: wrap;align-items: center;gap: var(--chip-gap, 0.5rem);flex: 1 1 16rem;min-width: 0; |
archive-filter-chip-toggle | cursor: pointer;appearance: none; |
archive-filter-chip-toggle-active | background-color: var(--color-chip-hover-background);border-color: var(--color-chip-hover-border);color: var(--color-chip-hover-text); |
archive-filter-active | display: flex;flex-wrap: wrap;align-items: center;gap: var(--chip-gap, 0.5rem); |
archive-filter-chip | & { appearance: none; cursor: pointer; display: inline-flex; align-items: center; gap: var(--chip-gap); font-size: var(--chip-font-size); line-height: var(--chip-line-height); letter-spacing: calc(var(--chip-letter-spacing, 0) * 1px); padding-inline: var(--chip-padding-x); padding-block: var(--chip-padding-y); border-radius: var(--chip-border-radius); border-style: solid; border-width: var(--chip-border-width); background-color: var(--color-chip-normal-background); border-co…+ 1 more rule |
archive-filter-chip-remove | font-size: 1.2em;line-height: 1;opacity: 0.7; |
post-related-title | margin-bottom: var(--gap-sm, 1.25rem); |
post-single | & .article > :not(section, .figure-w-full, .figure-w-screen, .figure-w-container, a:not([class*="card"]), div:not(.columns, .stack, [class*="card"], [class*="accordion"])) { max-width: var(--post-reading-width, calc(var(--container-width) - (var(--article-padding-x) * 2))); } |
post-share-button-label | font-size: var(--text-small-font-size, 0.875rem);line-height: 1; |
post-share-icon-label | & .post-share-list > a { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; }& .post-share-list > button { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; } |
post-share-list | .post-share-icon-label .post-share-list > a { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; }.post-share-icon-label .post-share-list > button { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; }.post-share-pills .post-share-list > a { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; }+ 3 more rules |
post-share-pills | & .post-share-list > a { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; }& .post-share-list > button { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; }& .post-share-list > a { padding: var(--gap-xs, 0.5rem) var(--gap-sm, 1.25rem); border-radius: 9999px; border: 1px solid var(--color-separator-line, currentColor); }+ 1 more rule |
post-share-sticky-bottom | position: fixed;left: 0;right: 0;bottom: 0;z-index: 40;justify-content: center;background-color: var(--color-foundations-surface-bg);border-top: 1px solid var(--color-separator-line, currentColor);padding-block: var(--gap-sm, 1.25rem); |
post-share-floating-sidebar | @media (width >= 1192px) { & { position: fixed; left: var(--gap-lg, 1.5rem); top: 50%; transform: translateY(-50%); flex-direction: column; align-items: center; z-index: 40; } } |
Next.jsLink to this section
@systhemaui/next re-exports ArchiveSearchFilter from @systhemaui/react unchanged. It is a client component. In a Payload project, use the archive filter pipeline from @systhemaui/payload/next; see Archive pages.
AccessibilityLink to this section
- The search field sits in a
role="search"form and is named by its placeholder. - Each dropdown is named by its placeholder ("Select a category"); each chip group is a
role="group"with the same name, and the active chip hasaria-pressed="true". - Each removable chip is a button named "Remove filter " (
labels.removeFiltertranslates it). - Changes do not move focus. Pair the filter with a listing that announces its result, as
PostsListdoes for its empty state.