Components
Chip Group
A primitive component that automatically collapses overflowing chips, exposing the hidden set via renderMore / onOverflowChange for consumer-side composition
Installation
$npx litefy@latest add chip-group
$pnpm dlx litefy@latest add chip-group
$yarn dlx litefy@latest add chip-group
$bun --bun litefy@latest add chip-group
Usage
Basic Usage
The component is a pure primitive with two responsibilities: rendering chips and overflow-collapse measurement. It ships no built-in +N trigger and no built-in panel — the hidden set is exposed via the onOverflowChange callback, and renderMore(hidden) renders a custom trigger inline (the example composes a +N popover with Popover):
ReactTypeScriptTailwind CSSViteNext.jsWeb ComponentsAccessibilityDesign SystemCSSPerformance
- React
- TypeScript
- Tailwind CSS
- Vite
- Next.js
- Web Components
- Accessibility
- Design System
- CSS
- Performance
Container width: 240px
"use client";
import { useState } from "react";
import { ChipGroup, Popover, Slider } from "@/ui";
import type { ChipItem } from "@/ui";
const tags: ChipItem[] = [
{ value: "react", label: "React" },
{ value: "typescript", label: "TypeScript" },
{ value: "tailwindcss", label: "Tailwind CSS" },
{ value: "vite", label: "Vite" },
{ value: "next-js", label: "Next.js" },
{ value: "web-components", label: "Web Components" },
{ value: "accessibility", label: "Accessibility" },
{ value: "design-system", label: "Design System" },
{ value: "css", label: "CSS" },
{ value: "performance", label: "Performance" },
];
export default function ChipGroupBasicDemo() {
const [width, setWidth] = useState(240);
return (
<div className="flex w-80 max-w-full flex-col gap-3">
<div style={{ width }}>
<ChipGroup
items={tags}
className="max-w-full"
renderMore={(hidden) => (
<Popover
trigger={`+${hidden.length}`}
classNames={{
trigger:
"cursor-pointer rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-colors hover:bg-primary hover:text-primary-foreground",
}}
>
<ul className="flex max-w-60 flex-col">
{tags.map((item) => (
<li
key={item.value}
className="truncate px-2 py-1.5 text-sm text-muted-foreground"
>
{item.label}
</li>
))}
</ul>
</Popover>
)}
/>
</div>
<Slider
min={120}
max={320}
step={4}
value={width}
onChange={setWidth}
aria-label="Container width"
/>
<p className="text-sm text-muted-foreground">Container width: {width}px</p>
</div>
);
}
Key points:
- The measurement approach is "shadow container + greedy accumulation": every chip renders inside an absolutely positioned off-viewport shadow container; widths (
offsetWidthplus gaps) accumulate one by one and stop as soon as the next chip would overflow; when overflowing and arenderMoretrigger exists, the available width is first reduced by the trigger's actual width (taken from the real inline element'soffsetWidth) and the fit runs again. - Chips that pass the stopping rule stay visible; the remaining items are handed to the consumer via
renderMore(hidden)andonOverflowChange(hidden). - Width changes (window resize, container dragging, font loading) trigger a recompute through
ResizeObserver— no business-side wiring needed. - The cost is a small amount of JS measurement, traded for stable logic and layout freedom: the rendered width and the visible count always agree.
- Chips default to
max-w-40 truncateso a single long label cannot break the layout; all chips areshrink-0to keep measured and rendered widths identical. renderMoreis the only inline mount point: the+Ntrigger must participate in inline width reservation, so it cannot be rendered entirely from outside. The component reserves its inline slot via a render prop, while the presentation (Popover, Tooltip, plain text) is fully up to the consumer.
Tooltip Composition
Returning a Tooltip-wrapped button from renderMore gives hover preview of the collapsed content:
ReactTypeScriptTailwind CSSViteNext.jsWeb ComponentsAccessibilityDesign SystemCSSPerformance
"use client";
import { ChipGroup, Tooltip, TooltipContent, TooltipTrigger } from "@/ui";
import type { ChipItem } from "@/ui";
const tags: ChipItem[] = [
{ value: "react", label: "React" },
{ value: "typescript", label: "TypeScript" },
{ value: "tailwindcss", label: "Tailwind CSS" },
{ value: "vite", label: "Vite" },
{ value: "next-js", label: "Next.js" },
{ value: "web-components", label: "Web Components" },
{ value: "accessibility", label: "Accessibility" },
{ value: "design-system", label: "Design System" },
{ value: "css", label: "CSS" },
{ value: "performance", label: "Performance" },
];
export default function ChipGroupTooltipDemo() {
return (
<ChipGroup
items={tags}
className="max-w-xs"
renderMore={(hidden) =>
hidden.length > 0 && (
<Tooltip>
<TooltipTrigger className="shrink-0 cursor-pointer rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium whitespace-nowrap">
+{hidden.length}
</TooltipTrigger>
<TooltipContent>
<span className="flex max-w-60 flex-wrap gap-1">
{hidden.map((item) => (
<span
key={item.value}
className="rounded-full bg-muted px-2 py-0.5 text-xs whitespace-nowrap"
>
{item.label}
</span>
))}
</span>
</TooltipContent>
</Tooltip>
)
}
/>
);
}
Key points:
- The trigger style and displayed content are fully controlled by the consumer; the example shows the hidden items.
- The component does not care what is inside
renderMore— it only reserves the inline slot and measures its width.
API Reference
ChipGroup
| Prop | Type | Default | Description |
|---|---|---|---|
items | ChipItem[] | - | All chip items |
onOverflowChange | (hidden: ChipItem[]) => void | - | Fired when the hidden set changes, carrying the hidden items |
renderMore | (hidden: ChipItem[]) => React.ReactNode | - | Renders a custom trigger inline; hidden is the collapsed items |
className | ClassNameValue | - | Custom class names, applied to the outer container (set max-width here) |
classNames | chip | - | Custom class names per slot |
ChipItem
| Field | Type | Default | Description |
|---|---|---|---|
value | string | - | Unique identity |
label | React.ReactNode | - | Chip text |