{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tag-group","type":"registry:ui","title":"Tag group","description":"Chips with dots, avatars, flags, icons, counts and remove buttons, selectable singly or in sets; a removed chip blurs out while its neighbours slide closed.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/ui/tag-group.tsx","type":"registry:ui","target":"components/ui/tag-group.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useIsPresent, useReducedMotion, type Transition } from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TagSize = \"sm\" | \"md\" | \"lg\";\nexport type TagDotTone = \"success\" | \"warning\" | \"danger\" | \"info\" | \"volt\" | \"muted\";\nexport type TagSelectionMode = \"none\" | \"single\" | \"multiple\";\n\n/** A flag drawn as stripes (and an optional centre disc) — pass any CSS colours. */\nexport interface TagFlag {\n  /** Stripe colours, first = left (vertical) or top (horizontal). */\n  stripes: string[];\n  /** @default \"vertical\" */\n  orientation?: \"vertical\" | \"horizontal\";\n  /** Colour of a centred disc, for flags like Japan or Bangladesh. */\n  disc?: string;\n}\n\nconst tagSize: Record<TagSize, string> = {\n  sm: \"h-6 gap-1 px-2 text-xs rounded-[7px]\",\n  md: \"h-7 gap-1.5 px-2.5 text-[13px] rounded-[8px]\",\n  lg: \"h-8 gap-1.5 px-3 text-sm rounded-[9px]\",\n};\nconst leadPad: Record<TagSize, string> = { sm: \"pl-1\", md: \"pl-1.5\", lg: \"pl-1.5\" };\nconst trailPad: Record<TagSize, string> = { sm: \"pr-1\", md: \"pr-1\", lg: \"pr-1.5\" };\nconst mediaSize: Record<TagSize, string> = { sm: \"size-4 text-[8px]\", md: \"size-[18px] text-[9px]\", lg: \"size-5 text-[10px]\" };\nconst iconSize: Record<TagSize, string> = { sm: \"[&_svg]:size-3\", md: \"[&_svg]:size-3.5\", lg: \"[&_svg]:size-4\" };\nconst boxSize: Record<TagSize, string> = { sm: \"size-3 rounded-[3px]\", md: \"size-3.5 rounded-[4px]\", lg: \"size-4 rounded-[5px]\" };\nconst closeSize: Record<TagSize, string> = { sm: \"size-4 [&_svg]:size-2.5\", md: \"size-[18px] [&_svg]:size-3\", lg: \"size-5 [&_svg]:size-3.5\" };\nconst dotTone: Record<TagDotTone, string> = {\n  success: \"bg-success\",\n  warning: \"bg-warning\",\n  danger: \"bg-danger\",\n  info: \"bg-info\",\n  volt: \"bg-volt\",\n  muted: \"bg-ink-subtle\",\n};\n\nconst pop: Transition = { type: \"spring\", stiffness: 520, damping: 34 };\nconst settle: Transition = { type: \"spring\", stiffness: 420, damping: 32 };\n\ninterface TagGroupContextValue {\n  size: TagSize;\n  mode: TagSelectionMode;\n  isSelected: (value: string) => boolean;\n  toggle: (value: string) => void;\n  onRemove?: (value: string) => void;\n  remove: (value: string, row: HTMLElement | null) => void;\n}\n\nconst TagGroupContext = React.createContext<TagGroupContextValue | null>(null);\n\nexport interface TagProps {\n  /** Identity inside a TagGroup (selection and removal). Defaults to the text label. */\n  value?: string;\n  children: React.ReactNode;\n  /** Plain-text label for screen readers when children are not a string. */\n  textValue?: string;\n  /** Overrides the group's size. @default \"md\" */\n  size?: TagSize;\n  /** Leading status dot; `true` is success. */\n  dot?: boolean | TagDotTone;\n  /** Leading avatar drawn from one or two initials. */\n  initials?: string;\n  /** Leading avatar node (an <img> or SVG); clipped to a circle. */\n  avatar?: React.ReactNode;\n  /** Leading icon. */\n  icon?: React.ReactNode;\n  /** Leading round flag. */\n  flag?: TagFlag;\n  /** Trailing count pill. */\n  count?: number;\n  disabled?: boolean;\n  /** Shows a remove button. Inside a TagGroup, the group's onRemove does the same. */\n  onRemove?: () => void;\n  className?: string;\n}\n\n/** Compact chip with optional dot, avatar, flag or icon, a count and a remove button; inside a TagGroup it becomes a selectable grid row. */\nexport const Tag = React.forwardRef<HTMLSpanElement, TagProps>(function Tag(\n  { value, children, textValue, size: own, dot, initials, avatar, icon, flag, count, disabled, onRemove, className },\n  ref,\n) {\n  const group = React.useContext(TagGroupContext);\n  const reduce = useReducedMotion();\n  const isPresent = useIsPresent();\n  const size = own ?? group?.size ?? \"md\";\n  const text = textValue ?? (typeof children === \"string\" || typeof children === \"number\" ? String(children) : value ?? \"\");\n  const key = value ?? text;\n  const selectable = Boolean(group && group.mode !== \"none\");\n  const selected = selectable && group ? group.isSelected(key) : false;\n  const removable = Boolean(onRemove || group?.onRemove) && !disabled;\n  const hasLead = Boolean(initials || avatar || flag);\n  const rowRef = React.useRef<HTMLSpanElement>(null);\n  React.useImperativeHandle(ref, () => rowRef.current as HTMLSpanElement);\n\n  const doRemove = () => {\n    if (group) group.remove(key, rowRef.current);\n    onRemove?.();\n  };\n\n  const lead = (\n    <>\n      {selectable && <MiniCheck checked={selected} size={size} disabled={disabled} reduce={Boolean(reduce)} />}\n      {dot && <span aria-hidden className={cn(\"size-1.5 shrink-0 rounded-full\", dotTone[dot === true ? \"success\" : dot])} />}\n      {flag && <Flag flag={flag} size={size} />}\n      {!flag && (avatar || initials) && (\n        <span\n          aria-hidden\n          className={cn(\n            \"grid shrink-0 place-items-center overflow-hidden rounded-full border border-border bg-surface-raised font-semibold uppercase leading-none text-ink-muted [&>img]:size-full [&>img]:object-cover\",\n            mediaSize[size],\n          )}\n        >\n          {avatar ?? initials?.slice(0, 2)}\n        </span>\n      )}\n      {icon && <span aria-hidden className={cn(\"grid shrink-0 place-items-center text-ink-subtle\", iconSize[size])}>{icon}</span>}\n    </>\n  );\n\n  const body = (\n    <>\n      {lead}\n      <span className=\"truncate\">{children}</span>\n      {count !== undefined && (\n        <span\n          className={cn(\n            \"rounded-[4px] px-1 font-mono text-[10.5px] font-medium leading-4 tabular-nums transition-colors\",\n            selected ? \"bg-volt-soft text-volt-ink\" : \"bg-surface-raised text-ink-muted\",\n          )}\n        >\n          {count}\n        </span>\n      )}\n      {removable && (\n        <button\n          type=\"button\"\n          tabIndex={group ? -1 : 0}\n          aria-label={`Remove ${text}`}\n          onClick={(e) => {\n            e.stopPropagation();\n            doRemove();\n          }}\n          className={cn(\n            \"grid shrink-0 place-items-center rounded-full text-ink-subtle outline-none transition-colors duration-150\",\n            \"hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)]\",\n            closeSize[size],\n          )}\n        >\n          <X aria-hidden strokeWidth={2.5} />\n        </button>\n      )}\n    </>\n  );\n\n  const shell = cn(\n    \"relative inline-flex max-w-full shrink-0 select-none items-center whitespace-nowrap border font-medium outline-none\",\n    \"transition-[background-color,border-color,color,box-shadow] duration-300 ease-spring\",\n    tagSize[size],\n    (hasLead || selectable) && leadPad[size],\n    removable && trailPad[size],\n    selected ? \"border-volt-ink/50 bg-volt-soft text-ink\" : \"border-border-strong bg-surface text-ink\",\n    selectable && !disabled && !selected && \"hover:bg-surface-hover\",\n    disabled && \"cursor-not-allowed opacity-45\",\n    className,\n  );\n\n  const motionProps = {\n    layout: \"position\" as const,\n    initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, filter: \"blur(4px)\" },\n    animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(6px)\" },\n    transition: { layout: settle, default: pop },\n  };\n\n  if (!group) {\n    return (\n      <motion.span ref={rowRef} {...motionProps} className={shell} aria-disabled={disabled || undefined}>\n        {body}\n      </motion.span>\n    );\n  }\n\n  return (\n    <motion.span\n      ref={rowRef}\n      {...motionProps}\n      role=\"row\"\n      data-tag-row=\"\"\n      data-value={key}\n      data-exiting={isPresent ? undefined : \"\"}\n      data-removable={removable ? \"\" : undefined}\n      aria-selected={selectable ? selected : undefined}\n      aria-disabled={disabled || undefined}\n      aria-label={text || undefined}\n      whileTap={selectable && !disabled && !reduce ? { scale: 0.96 } : undefined}\n      onClick={() => {\n        if (selectable && !disabled) group.toggle(key);\n      }}\n      className={cn(shell, selectable && !disabled && \"cursor-pointer\", \"focus-visible:shadow-[var(--focus-ring)]\")}\n    >\n      <span role=\"gridcell\" className=\"inline-flex min-w-0 items-center gap-[inherit]\">\n        {body}\n      </span>\n    </motion.span>\n  );\n});\n\nfunction MiniCheck({ checked, size, disabled, reduce }: { checked: boolean; size: TagSize; disabled?: boolean; reduce: boolean }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"grid shrink-0 place-items-center border transition-[background-color,border-color] duration-200\",\n        boxSize[size],\n        checked ? \"border-volt bg-volt text-on-volt\" : \"border-border-strong bg-surface-sunken\",\n        disabled && !checked && \"bg-surface-hover\",\n      )}\n    >\n      <svg viewBox=\"0 0 16 16\" className=\"size-[80%]\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.6} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <motion.path\n          d=\"M3.5 8.5l3 3 6-7\"\n          initial={false}\n          animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}\n          transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 30 }}\n        />\n      </svg>\n    </span>\n  );\n}\n\nfunction Flag({ flag, size }: { flag: TagFlag; size: TagSize }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"relative flex shrink-0 overflow-hidden rounded-full shadow-[inset_0_0_0_1px_var(--border)]\",\n        flag.orientation === \"horizontal\" ? \"flex-col\" : \"flex-row\",\n        mediaSize[size],\n      )}\n    >\n      {flag.stripes.map((c, i) => (\n        <span key={i} className=\"flex-1\" style={{ backgroundColor: c }} />\n      ))}\n      {flag.disc && <span className=\"absolute left-1/2 top-1/2 size-[46%] -translate-x-1/2 -translate-y-1/2 rounded-full\" style={{ backgroundColor: flag.disc }} />}\n      <span className=\"absolute inset-0 rounded-full shadow-[inset_0_0_0_1px_var(--border)]\" />\n    </span>\n  );\n}\n\nexport interface TagGroupProps {\n  /** Names the group for assistive tech; shown above the tags unless hideLabel. */\n  label: string;\n  hideLabel?: boolean;\n  /** @default \"md\" */\n  size?: TagSize;\n  /** \"single\" keeps exactly one tag picked once chosen; \"multiple\" toggles. @default \"none\" */\n  selectionMode?: TagSelectionMode;\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  /** Enables remove buttons and Backspace/Delete on every tag. */\n  onRemove?: (value: string) => void;\n  /** Shown when there are no tags. */\n  emptyState?: React.ReactNode;\n  className?: string;\n  /** Tag elements, each with a stable key. */\n  children?: React.ReactNode;\n}\n\n/** Keyboard-navigable set of tags: arrows move between chips, Space selects, Backspace removes, and neighbours slide closed as a chip leaves. */\nexport function TagGroup({\n  label,\n  hideLabel,\n  size = \"md\",\n  selectionMode = \"none\",\n  value,\n  defaultValue = [],\n  onValueChange,\n  onRemove,\n  emptyState,\n  className,\n  children,\n}: TagGroupProps) {\n  const auto = React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const [own, setOwn] = React.useState<string[]>(defaultValue);\n  const selected = value ?? own;\n  const grid = React.useRef<HTMLDivElement>(null);\n  const focusAfterRemoval = React.useRef<number | null>(null);\n  const lastFocused = React.useRef<string | null>(null);\n\n  const set = (next: string[]) => {\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  const ctx: TagGroupContextValue = {\n    size,\n    mode: selectionMode,\n    isSelected: (v) => selected.includes(v),\n    toggle: (v) => {\n      if (selectionMode === \"single\") {\n        if (!selected.includes(v)) set([v]);\n      } else if (selectionMode === \"multiple\") {\n        set(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v]);\n      }\n    },\n    onRemove,\n    remove: (v, row) => {\n      const rows = liveRows(grid.current);\n      const at = row ? rows.indexOf(row) : -1;\n      if (row && row.contains(document.activeElement)) focusAfterRemoval.current = Math.max(0, at);\n      if (selected.includes(v)) set(selected.filter((x) => x !== v));\n      onRemove?.(v);\n    },\n  };\n\n  // Roving tabindex: one tab stop, on the last focused tag, else the first selected, else the first enabled.\n  React.useLayoutEffect(() => {\n    const rows = liveRows(grid.current);\n    const enabled = rows.filter((r) => r.getAttribute(\"aria-disabled\") !== \"true\");\n    const stop =\n      enabled.find((r) => r.dataset.value === lastFocused.current) ?? enabled.find((r) => r.getAttribute(\"aria-selected\") === \"true\") ?? enabled[0];\n    rows.forEach((r) => {\n      r.tabIndex = r === stop ? 0 : -1;\n    });\n    if (focusAfterRemoval.current !== null) {\n      const target = enabled[Math.min(focusAfterRemoval.current, enabled.length - 1)];\n      focusAfterRemoval.current = null;\n      if (target) {\n        lastFocused.current = target.dataset.value ?? null;\n        target.tabIndex = 0;\n        target.focus();\n      }\n    }\n  });\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const row = (e.target as HTMLElement).closest<HTMLElement>(\"[data-tag-row]\");\n    if (!row || e.target !== row) return;\n    const enabled = liveRows(grid.current).filter((r) => r.getAttribute(\"aria-disabled\") !== \"true\");\n    const at = enabled.indexOf(row);\n    let next: HTMLElement | undefined;\n    switch (e.key) {\n      case \"ArrowRight\":\n      case \"ArrowDown\":\n        next = enabled[Math.min(at + 1, enabled.length - 1)];\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowUp\":\n        next = enabled[Math.max(at - 1, 0)];\n        break;\n      case \"Home\":\n        next = enabled[0];\n        break;\n      case \"End\":\n        next = enabled[enabled.length - 1];\n        break;\n      case \" \":\n      case \"Enter\":\n        if (selectionMode !== \"none\" && row.dataset.value !== undefined && at >= 0) {\n          e.preventDefault();\n          ctx.toggle(row.dataset.value);\n        }\n        return;\n      case \"Backspace\":\n      case \"Delete\":\n        if (row.dataset.removable !== undefined && row.dataset.value !== undefined) {\n          e.preventDefault();\n          row.querySelector<HTMLButtonElement>(\"button[aria-label]\")?.click();\n        }\n        return;\n      default:\n        return;\n    }\n    if (next) {\n      e.preventDefault();\n      lastFocused.current = next.dataset.value ?? null;\n      liveRows(grid.current).forEach((r) => {\n        r.tabIndex = r === next ? 0 : -1;\n      });\n      next.focus();\n    }\n  };\n\n  const labelId = `tg${auto}-label`;\n  const count = React.Children.count(children);\n  return (\n    <div className={cn(\"grid min-w-0 gap-2\", className)}>\n      <span id={labelId} className={cn(\"text-[13px] font-medium leading-[18px] text-ink\", hideLabel && \"sr-only\")}>\n        {label}\n      </span>\n      <TagGroupContext.Provider value={ctx}>\n        <div\n          ref={grid}\n          role=\"grid\"\n          aria-labelledby={labelId}\n          aria-multiselectable={selectionMode === \"multiple\" || undefined}\n          onKeyDown={onKeyDown}\n          onFocus={(e) => {\n            const row = (e.target as HTMLElement).closest<HTMLElement>(\"[data-tag-row]\");\n            if (!row || row.dataset.value === undefined) return;\n            lastFocused.current = row.dataset.value;\n            liveRows(grid.current).forEach((r) => {\n              r.tabIndex = r === row ? 0 : -1;\n            });\n          }}\n          className=\"relative flex flex-wrap items-center gap-1.5\"\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {children}\n          </AnimatePresence>\n          {count === 0 && emptyState && <p className=\"m-0 text-[13px] text-ink-subtle\">{emptyState}</p>}\n        </div>\n      </TagGroupContext.Provider>\n    </div>\n  );\n}\n\nfunction liveRows(root: HTMLElement | null): HTMLElement[] {\n  if (!root) return [];\n  return Array.from(root.querySelectorAll<HTMLElement>(\"[data-tag-row]:not([data-exiting])\"));\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","tag","chip","filter","tag group","removable","selection"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/tag-group"}}