{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chip-group","type":"registry:ui","title":"Chip group","description":"Filter by a few facets with chips that morph as you pick them.","categories":["inputs"],"dependencies":["motion"],"registryDependencies":["https://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/chip-group/chip-group.tsx","type":"registry:component","target":"@components/arc/chip-group/chip-group.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode, Ref, RefObject } from \"react\";\nimport { AnimatePresence, LayoutGroup, animate, motion, useIsPresent, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport type { AnimationPlaybackControls, HTMLMotionProps, MotionValue, TargetAndTransition, Transition } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./chip-group.module.css\";\n\nexport interface ChipOption { value: string; label: string; }\n\n/**\n * Selectable filter chips for narrowing a list by facets people toggle often, such as topics, statuses, or tags.\n * Selecting morphs the chip: a check grows in, the label slides over, and the chip's edge follows on a spring while its\n * neighbours glide to their new places, even across lines. Long sets fold behind a “+N more” chip that opens with a height morph.\n * Arrow keys move between chips, Space or Enter toggles, and each chip is a pressed or unpressed button.\n */\nexport interface ChipGroupProps {\n options: ChipOption[];\n value: string[];\n onValueChange: (value: string[]) => void;\n /** Accessible name of the group, such as “Topics”. */\n label: string;\n /** Allow several chips at once. In single mode the selected chip can still be cleared. */\n multiple?: boolean;\n /** Chips shown before the rest fold behind a “+N more” chip. Chips selected when it folds stay in view. */\n maxVisible?: number;\n className?: string;\n}\n\n/** Roving-focus key of the overflow chip; the NUL keeps it from colliding with any option value. */\nconst MORE = \"\\u0000more\";\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst enterEase = [...motionTokens.ease.enter] as [number, number, number, number];\nconst fade: Transition = { duration: motionTokens.duration.fast, ease: standard };\nconst reducedFade: Transition = { duration: .15, ease: standard };\nconst exitFast: Transition = { duration: motionTokens.duration.fast, ease: standard };\nconst shown: TargetAndTransition = { opacity: 1, y: \"0em\", scale: 1, filter: \"blur(0px)\" };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };\n\n/**\n * A chip's layout width changes in one frame (so the row can re-flow once and neighbours can glide), but what you see must not.\n * `lag` is how far the visible edge trails the layout edge: it jumps by the change, then springs back to zero.\n * Passive resizes, like a late web font, only re-baseline it.\n */\nfunction useWidthLag(node: RefObject, key: string, reduce: boolean) {\n const lag = useMotionValue(0);\n const width = useRef(null);\n useLayoutEffect(() => {\n const element = node.current;\n if (!element) return;\n const next = element.offsetWidth, previous = width.current;\n width.current = next;\n if (previous === null || previous === next) return;\n if (reduce) { lag.jump(0); return; }\n // Retarget from wherever the edge is now and keep its speed, so fast toggling never snaps.\n const velocity = lag.getVelocity();\n lag.jump(lag.get() + previous - next);\n animate(lag, 0, { ...motionTokens.spring.morph, velocity });\n }, [key, lag, node, reduce]);\n useEffect(() => {\n const element = node.current;\n if (!element || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => { width.current = element.offsetWidth; });\n observer.observe(element);\n return () => observer.disconnect();\n }, [node]);\n return lag;\n}\n\n/** Follows the chips' height. After `morphKey` changes (a selection, opening the overflow) it springs from the old height to the new one, then returns to auto. It clips only while moving, so nothing is cut off at rest. */\nfunction HeightFrame({ morphKey, reduce, children }: { morphKey: string; reduce: boolean; children: ReactNode }) {\n const frame = useRef(null);\n const content = useRef(null);\n const height = useMotionValue(\"auto\");\n const changedAt = useRef(0);\n useLayoutEffect(() => { changedAt.current = performance.now(); }, [morphKey]);\n useEffect(() => {\n const node = content.current;\n if (!node || typeof ResizeObserver === \"undefined\") return;\n let last: number | undefined;\n let controls: AnimationPlaybackControls | undefined;\n const settle = () => { height.jump(\"auto\"); if (frame.current) Object.assign(frame.current.style, { overflow: \"\", height: \"auto\", minHeight: \"\" }); };\n // The minimum follows the moving height, so a flex parent short on room cannot squeeze the frame mid-morph and snap it when the morph ends.\n const unfollow = height.on(\"change\", value => { if (frame.current) frame.current.style.minHeight = typeof value === \"number\" ? `${value}px` : \"\"; });\n const observer = new ResizeObserver(([entry]) => {\n const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;\n const current = height.get();\n const from = typeof current === \"number\" ? current : last;\n last = next;\n controls?.stop();\n if (reduce || from === undefined || Math.abs(from - next) < .5 || performance.now() - changedAt.current > 160) return settle();\n // Pin the old height before this frame paints, then spring to the new one.\n if (frame.current) Object.assign(frame.current.style, { overflow: \"clip\", height: `${from}px`, minHeight: `${from}px` });\n controls = animate(height, [from, next], { ...motionTokens.spring.smooth, onComplete: settle });\n });\n observer.observe(node);\n return () => { observer.disconnect(); unfollow(); controls?.stop(); };\n }, [height, reduce]);\n return \n
{children}
\n
;\n}\n\n/** Outgoing copies are hidden from assistive tech while they fade, so the button reads only its current text.\n * `follow` is the centring offset of the current line; a leaving line holds the offset it had when it left, so the new line's centring never drags it sideways. */\nfunction Swap({ follow, style, ...props }: HTMLMotionProps<\"span\"> & { follow?: MotionValue }) {\n const present = useIsPresent();\n const held = useMotionValue(0);\n // Runs before the chip re-measures its width in the same commit, so this reads the offset from before the change.\n useLayoutEffect(() => { if (!present && follow) held.jump(follow.get()); }, [present, follow, held]);\n return ;\n}\n\ninterface ChipProps {\n option: ChipOption;\n selected: boolean;\n tabbable: boolean;\n reduce: boolean;\n delay: number;\n onToggle: (value: string) => void;\n onFocusChip: (value: string) => void;\n ref?: Ref;\n}\n\n/** The width the check takes from the label: a 14px glyph and the space after it. */\nconst SLOT = 18;\n\nfunction Chip({ option, selected, tabbable, reduce, delay, onToggle, onFocusChip, ref }: ChipProps) {\n const present = useIsPresent();\n const body = useRef(null);\n const lag = useWidthLag(body, String(selected), reduce);\n /* How far the check has grown: the slot it was given, less the width the label still lags behind. It follows both inputs\n explicitly, so a jump with no animation (reduced motion) still lands the check in its final state. */\n const slot = useRef(selected ? SLOT : 0);\n const grown = useMotionValue(selected ? 1 : 0);\n const edge = useTransform(lag, value => -value);\n // The check grows from zero width in exactly the gap the label opens as it slides over, so the two never overlap, and deselecting runs it backwards.\n const checkScale = useTransform(grown, value => Math.min(value, 1.1));\n const checkOpacity = useTransform(grown, value => Math.min(1, value * 1.4));\n const checkBlur = useTransform(grown, value => value >= 1 ? \"none\" : `blur(${((1 - value) * motionTokens.blur.subtle).toFixed(2)}px)`);\n /* The stroke draws in step with the growth, so the check writes itself as the label makes room. */\n const checkDraw = useTransform(grown, value => Math.min(1, Math.max(.001, value)));\n // Declared after the transforms so they are already subscribed when a reduced-motion selection jumps straight to the end.\n useLayoutEffect(() => {\n slot.current = selected ? SLOT : 0;\n const update = () => grown.set(Math.max(0, (slot.current + lag.get()) / SLOT));\n update();\n return lag.on(\"change\", update);\n }, [selected, lag, grown]);\n // Revealed chips grow in with a short stagger; chips that fold away leave faster than they came.\n const enter: Transition = reduce ? { layout: { duration: 0 }, default: reducedFade } : { layout: motionTokens.spring.morph, default: { ...motionTokens.spring.snappy, delay }, opacity: { ...fade, delay } };\n return onToggle(option.value)} onFocus={() => onFocusChip(option.value)}\n layout=\"position\" initial={reduce ? { opacity: 0 } : { opacity: 0, scale: .9 }} animate={{ opacity: 1, scale: 1 }} exit={reduce ? { opacity: 0, transition: reducedFade } : { opacity: 0, scale: .9, transition: { duration: motionTokens.duration.instant, ease: standard } }} transition={enter}>\n \n \n \n \n {option.label}\n \n ;\n}\n\ninterface MoreChipProps { hidden: number; expanded: boolean; tabbable: boolean; reduce: boolean; onToggle: () => void; onFocusChip: (value: string) => void; ref?: Ref; }\n\nfunction MoreChip({ hidden, expanded, tabbable, reduce, onToggle, onFocusChip, ref }: MoreChipProps) {\n const body = useRef(null);\n const text = expanded ? \"Show less\" : `+${hidden} more`;\n const lag = useWidthLag(body, text, reduce);\n const edge = useTransform(lag, value => -value);\n // The text stays centred on the visible surface while its edge catches up.\n const centre = useTransform(lag, value => value / 2);\n return onFocusChip(MORE)} layout=\"position\" transition={{ layout: reduce ? { duration: 0 } : motionTokens.spring.morph }}>\n \n \n \n \n {text}\n \n \n \n ;\n}\n\nexport function ChipGroup({ options, value, onValueChange, label, multiple = true, maxVisible = Infinity, className }: ChipGroupProps) {\n const id = useId();\n const reduce = !!useReducedMotion();\n const [expanded, setExpanded] = useState(false);\n // Chips selected when the overflow folds stay in view, so a selection is never hidden and deselecting never makes a chip vanish.\n const [pinned, setPinned] = useState(value);\n const [active, setActive] = useState(null);\n const foldable = options.length > maxVisible;\n const visible = !foldable || expanded ? options : options.filter((option, index) => index < maxVisible || pinned.includes(option.value) || value.includes(option.value));\n const hidden = options.length - visible.length;\n const showMore = foldable && (expanded || hidden > 0);\n const keys = [...visible.map(option => option.value), ...(showMore ? [MORE] : [])];\n // One chip holds the tab stop: the last one focused, else the first selected, else the first.\n const tabStop = active !== null && keys.includes(active) ? active : visible.find(option => value.includes(option.value))?.value ?? keys[0];\n\n function toggle(next: string) {\n const on = value.includes(next);\n if (!multiple) return onValueChange(on ? [] : [next]);\n onValueChange(options.filter(option => option.value === next ? !on : value.includes(option.value)).map(option => option.value));\n }\n\n function toggleMore() {\n if (expanded) setPinned(value);\n setExpanded(!expanded);\n }\n\n function onKeyDown(event: KeyboardEvent) {\n const buttons = Array.from(event.currentTarget.querySelectorAll(\":is(button[data-chip], button[data-more]):not([aria-hidden='true'])\"));\n const index = buttons.indexOf(document.activeElement as HTMLButtonElement);\n if (index < 0) return;\n const last = buttons.length - 1;\n const moves: Record = { ArrowRight: index === last ? 0 : index + 1, ArrowDown: index === last ? 0 : index + 1, ArrowLeft: index === 0 ? last : index - 1, ArrowUp: index === 0 ? last : index - 1, Home: 0, End: last };\n if (!(event.key in moves)) return;\n event.preventDefault();\n buttons[moves[event.key]].focus();\n }\n\n return \n \n
\n \n {/* Chips revealed by the overflow grow in one after another, in reading order. */}\n {visible.map((option, index) => )}\n {showMore ? \n
\n
\n
;\n}\n\nexport default ChipGroup;\n"},{"path":"registry/components/chip-group/chip-group.module.css","type":"registry:component","target":"@components/arc/chip-group/chip-group.module.css","content":"/* The frame follows the chip rows' height on a spring; the rows re-flow once and every chip glides to its new place. */\n.frame { min-width: 0; }\n.content { position: relative; }\n.group { position: relative; isolation: isolate; display: flex; flex-wrap: wrap; gap: var(--space-2); }\n\n/* The button is only the hit area and the layout box. What you see is the surface, which trails the box on a spring while the width morphs. */\n.chip { position: relative; display: inline-flex; max-width: 100%; padding: 0; border: 0; border-radius: var(--radius-pill); background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; letter-spacing: var(--tracking-body); line-height: 20px; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }\n/* Press answers on the body with the individual scale property, so it never fights the layout transform on the button. */\n.body { position: relative; isolation: isolate; display: inline-flex; min-width: 0; height: var(--control-height-sm); align-items: center; padding: 0 14px; transition: scale var(--duration-spring) var(--ease-spring); }\n.chip:active .body { scale: .97; transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }\n.surface { position: absolute; z-index: -1; top: 0; bottom: 0; left: 0; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface); transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); }\n/* The check grows from its left edge where the label started; the slot hands its width to the label in one layout step, and the label's offset springs it over. */\n.check { position: absolute; top: calc(50% - 7px); left: 12px; display: grid; width: 14px; height: 14px; place-items: center; color: var(--accent-strong); transform-origin: 0 50%; }\n.check svg { display: block; overflow: visible; }\n.slot { width: 0; flex: none; }\n.body[data-selected=\"true\"] .slot { width: 18px; }\n.label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n\n/* Selected reads as a quiet tint of the accent (neutral by default) plus the check, never color alone. */\n.body[data-selected=\"true\"] .surface { border-color: color-mix(in oklch, var(--accent) 42%, var(--border)); background: color-mix(in oklch, var(--accent) 11%, var(--surface)); }\n.chip[aria-pressed=\"true\"] { color: var(--foreground); }\n@media (hover: hover) and (pointer: fine) {\n .chip:hover { color: var(--foreground); }\n .chip:hover .surface { border-color: var(--border-strong); }\n .chip[aria-pressed=\"true\"]:hover .surface { border-color: color-mix(in oklch, var(--accent) 60%, var(--border)); background: color-mix(in oklch, var(--accent) 16%, var(--surface)); }\n}\n\n/* The overflow chip travels under the chips it reveals, so they seem to come out of it. */\n.chip:not(.more) { z-index: 1; }\n.more { color: var(--text-muted); }\n.more .surface { border-color: var(--border); background: var(--surface-muted); }\n.moreText { position: relative; display: inline-flex; justify-content: center; font-variant-numeric: tabular-nums; }\n.moreLine { display: block; white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n .chip, .body, .surface { transition: none; }\n .chip:active .body { scale: none; }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/chip-group","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/chip-group","markdown":"https://uiarc.dev/components/chip-group/markdown","tags":["chips","filter","toggle"]}}