Docs

This page isn't translated yet

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.

Filter bar driving a listing
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 the id, so you can query by relationship id.
  • onFiltersChange receives { q, categoryId, tagId, postType } (EMPTY_ARCHIVE_FILTERS is the empty value). It fires on every change, but not on mount, so the server-rendered first page is not fetched twice.
  • Typing updates q after 300 ms; submitting the search form applies it at once.
  • Each active filter appears as a removable chip below the bar.
  • syncUrl (default true) mirrors the selection into the query string with history.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. Pass initial to 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.

Chips with a label
Filters
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.

Tags first, no search
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

PropTypeDefaultDescription
categoriesArchiveFilterOption[]-Available category filter pills.
tagsArchiveFilterOption[]-Available tag filter pills.
typesArchiveFilterOption[]-Available post-type filter pills.
orderreadonly 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.
filterStyleArchiveFilterStyle'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.
searchablebooleantrueShow the free-text search input.
searchPlaceholderstring'Search…'
filtersLabelstring-Optional leading label for the filter bar (the Figma "FILTERS" eyebrow). Rendered before the search/dropdowns when set; omitted entirely otherwise.
classNamestring-
initialPartial<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.
syncUrlbooleantrueWhen 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>
ClassStyles
posts-listwidth: 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-rowdisplay: 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-interspacewidth: 100%;
posts-list-emptycolor: color-mix(in srgb, currentColor 60%, transparent);text-align: center;padding-block: var(--gap-md, 2.5rem);
posts-list-actionsdisplay: 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-typecolor: 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-titletext-wrap: balance;
post-card-read-morecolor: var(--color-link-text-default, currentColor);font-weight: 600;text-decoration-line: underline;
post-highlight-read-morecolor: var(--color-link-text-default, currentColor);font-weight: 600;text-decoration-line: underline;
post-card-media-placeholderwidth: 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-highlightposition: relative;overflow: hidden;border-radius: var(--card-default-border-radius, 12px);
highlight-cardposition: relative;overflow: hidden;border-radius: var(--card-default-border-radius, 12px);
post-highlight-scrimposition: 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-overlayposition: relative;z-index: 1;
post-highlight-excerptcolor: color-mix(in srgb, #ffffff 85%, transparent);
post-metadisplay: flex;align-items: center;flex-wrap: wrap;gap: 0.5em;color: color-mix(in srgb, currentColor 60%, transparent);
post-byline-metadisplay: flex;align-items: center;flex-wrap: wrap;gap: 0.5em;color: color-mix(in srgb, currentColor 60%, transparent);
post-meta-avatarwidth: 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-separatoropacity: 0.5;
post-meta-author-role& { opacity: 0.7; }&::before { content: ", "; }
archive-filterdisplay: flex;flex-direction: column;gap: var(--gap-sm, 1.25rem);
archive-filter-bardisplay: flex;flex-wrap: wrap;align-items: center;gap: var(--gap-sm, 1.25rem);
archive-filter-labelflex: 0 0 auto;color: color-mix(in srgb, currentColor 60%, transparent);
archive-filter-searchflex: 1 1 16rem;min-width: 0;margin: 0;
archive-filter-selectflex: 1 1 16rem;min-width: 0;margin: 0;
archive-filter-chipsdisplay: flex;flex-wrap: wrap;align-items: center;gap: var(--chip-gap, 0.5rem);flex: 1 1 16rem;min-width: 0;
archive-filter-chip-togglecursor: pointer;appearance: none;
archive-filter-chip-toggle-activebackground-color: var(--color-chip-hover-background);border-color: var(--color-chip-hover-border);color: var(--color-chip-hover-text);
archive-filter-activedisplay: 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-removefont-size: 1.2em;line-height: 1;opacity: 0.7;
post-related-titlemargin-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-labelfont-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-bottomposition: 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 has aria-pressed="true".
  • Each removable chip is a button named "Remove filter " (labels.removeFilter translates it).
  • Changes do not move focus. Pair the filter with a listing that announces its result, as PostsList does for its empty state.