Category Cloud
alphav0.2.0Flex-wrapped cloud of clickable category chips with optional counts — single-select, toggleable, controlled or uncontrolled.
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
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/category-cloudAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/category-cloud-fixturesCLI 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
Filter by department
Demo source
"use client"; import { useState } from "react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { CategoryCloud } from "./category-cloud";import { DUMMY_CATEGORIES_EN, DUMMY_CATEGORIES_TR } from "./dummy-data"; export default function CategoryCloudDemo() { const [controlledValue, setControlledValue] = useState<string | null>(null); return ( <Tabs defaultValue="basic" className="w-full"> <SwipeTabsList> <TabsTrigger value="basic">Basic</TabsTrigger> <TabsTrigger value="counts">With counts</TabsTrigger> <TabsTrigger value="controlled">Controlled</TabsTrigger> <TabsTrigger value="i18n">Localized</TabsTrigger> </SwipeTabsList> <TabsContent value="basic" className="mt-6 max-w-md rounded-2xl border border-border/50 bg-card p-6"> <CategoryCloud items={["All", "Tech", "Design", "Engineering", "Marketing", "Operations"]} title="Filter by department" /> </TabsContent> <TabsContent value="counts" className="mt-6 max-w-md rounded-2xl border border-border/50 bg-card p-6"> <CategoryCloud items={DUMMY_CATEGORIES_EN} title="Categories" /> </TabsContent> <TabsContent value="controlled" className="mt-6 max-w-md space-y-4 rounded-2xl border border-border/50 bg-card p-6"> <CategoryCloud items={DUMMY_CATEGORIES_EN} title="Categories" value={controlledValue} onChange={setControlledValue} /> <p className="text-sm text-muted-foreground"> Active value: <code className="rounded bg-muted px-1.5 py-0.5 text-xs">{controlledValue ?? "null"}</code> </p> </TabsContent> <TabsContent value="i18n" className="mt-6 max-w-md rounded-2xl border border-border/50 bg-card p-6"> <CategoryCloud items={DUMMY_CATEGORIES_TR} title="Kategoriler" ariaLabel="Kategori filtresi" /> </TabsContent> </Tabs> );} 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>witharia-pressed. - Container is
role="group";aria-labeldefaults totitleif provided. - Tab moves between chips; Enter / Space activates. Focus-visible ring per chip.
- Heading level configurable via
headingAs={"h2" | "h3" | "h4"}. Defaulth3.
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