Skip to content
ilinxa/pro-ui

Category Cloud

alphav0.2.0

Flex-wrapped cloud of clickable category chips with optional counts — single-select, toggleable, controlled or uncontrolled.

Category: FormsUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

Context

Sidebar / inline filter affordance. Differs from `entity-picker` (popover-driven select with search) by being always-visible and count-augmented. Differs from `filter-panel` (multi-section schema-driven filter panel) by handling a single category dimension. Generic over category items via `string[]` shorthand or full `CategoryCloudItem[]` shape. Migration origin: kasder kas-social-front-v0 NewsMagazineGrid.tsx sidebar Categories block. Composed by `magazine-layout` in the news-domain family.

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
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/category-cloud

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/category-cloud-fixtures

Preview

Filter by department

Demo source

demo.tsxtsx

Usage

When to use

Reach for CategoryCloud when you have a fixed set of categories / tags / segments and want users to filter by clicking one. Always-visible flex-wrap of pill chips with optional counts. Single-select; re-clicking the active chip clears it (toggleable).

Minimal example

import { CategoryCloud } from "@/components/category-cloud";

<CategoryCloud items={["All", "Tech", "Design", "Engineering"]} />;

String-array shorthand is desugared internally to [{ value, label }]. No counts in this form.

With counts

<CategoryCloud
  items={[
    { value: "tech", label: "Technology", count: 12 },
    { value: "design", label: "Design", count: 8 },
  ]}
  title="Categories"
/>;

Controlled

const [active, setActive] = useState<string | null>(null);

<CategoryCloud
  items={categories}
  value={active}
  onChange={setActive}
/>;

For URL-state sync, drive value from your router and update via onChange. Pass null to clear.

Custom count format

<CategoryCloud
  items={items}
  formatCount={(c) => c > 999 ? ` (${(c/1000).toFixed(1)}k)` : ` (${c})`}
/>;

Disable toggle (always-active)

By default, re-clicking the active chip clears the selection. Pass toggleable={false} to disable this — useful when nullisn't a valid filter state in your app.

Accessibility

  • Each chip is a real <button> with aria-pressed.
  • Container is role="group"; aria-label defaults to title if provided.
  • Tab moves between chips; Enter / Space activates. Focus-visible ring per chip.
  • Heading level configurable via headingAs={"h2" | "h3" | "h4"}. Default h3.

Stable references

The card is React.memo-wrapped. Hoist the items array outside the parent render or memoize it to prevent unnecessary re-renders.

Features

  • Always-visible flex-wrap of pill chips (vs entity-picker's popover-driven select)
  • Optional inline counts via `count` field on items; configurable format via `formatCount` callback
  • String-array shorthand: `items={["All", "Tech"]}` desugars to `{value, label}` form
  • Controlled-or-uncontrolled value (single-select; pass null to clear)
  • Toggleable — re-clicking active clears (configurable via `toggleable` prop)
  • Native `<button>` semantics with `aria-pressed`; Tab + Enter/Space work natively
  • Optional editorial-header title with `pb-2 border-b` separator (matches the magazine sidebar header rhythm)
  • Heading semantic level configurable via `headingAs` (h2 | h3 | h4)
  • ARIA group with auto-derived label (from `title` or explicit `ariaLabel`)
  • React.memo wrapped

Tags

category-cloudformsfiltertagscategoriesmigration

Dependencies

shadcn primitives: badge