Inline Tag Group
A tag group with the look and feel of an input.
This tag group is built to look like an input form field. It uses React Aria Components under the hood to provide keyboard navigation and better accessibility.
Best Practices
Prefix React Aria Components' imports to avoid confusion with custom components.
import { Button as AriaButton } from 'react-aria-components';Group styles by splitting them into multiple lines with
clsx
.className={clsx('group relative flex ...','data-[focused]:outline-none data-[focused]:ring-2 ...',)}Access Tailwind CSS' theme with arbitrary values and
access an object's value by directly calling the index on the definition.const colors = {gray: "[--bg-color:theme('colors.gray.50')] ...",emerald: "[--bg-color:theme('colors.emerald.50')] ...",}[color];Define CSS custom properties on the parent component to access them on the children.
<div className="... bg-gradient-to-l from-[--bg-color] from-50% ...">
Requirements
Code
import { XMarkIcon } from '@heroicons/react/16/solid';import { useId } from '@react-aria/utils';import { useListData } from '@react-stately/data';import { clsx } from 'clsx';import { useRef } from 'react';import { Button as AriaButton, Input as AriaInput, Label as AriaLabel, type Key, Tag as AriaTag, TagGroup as AriaTagGroup, TagList as AriaTagList, type TagListProps as AriaTagListProps, type TagProps as AriaTagProps,} from 'react-aria-components';
interface TagProps extends Omit<AriaTagProps, 'className'> { color?: 'gray' | 'emerald';}
interface TagItem { id: number; name: string;}
export default function InlineTagGroupExample() { const labelId = useId(); let inputRef = useRef<HTMLInputElement>(null); let list = useListData<TagItem>({ initialItems: [ { id: 1, name: 'react-aria-components' }, { id: 2, name: 'tailwind-css' }, ], });
function addTag() { if (!inputRef.current) { return; }
const tagNames = inputRef.current.value.split(/[,;]/);
tagNames.forEach((tagName) => { const adjustedTagName = tagName .trim() .replace(/\s\s+/g, ' ') .replace(/\t|\\t|\r|\\r|\n|\\n/g, '');
if (adjustedTagName === '') { return; }
list.append({ id: (list.items.at(-1)?.id || 0) + 1, name: adjustedTagName, }); });
inputRef.current.value = ''; }
function handleRemove(keys: Set<Key>) { list.remove(...keys); }
function handleKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter' || e.key === ',' || e.key === ';') { e.preventDefault(); addTag(); } }
return ( <AriaTagGroup onRemove={handleRemove} className="w-full"> <AriaLabel className="block text-sm font-medium leading-6 text-gray-900" id={labelId} > Tags </AriaLabel> <div className="mt-2"> <div className="flex w-full flex-wrap items-center gap-1.5 rounded-md bg-white p-1.5 ring-1 ring-inset ring-gray-300 transition-shadow hover:ring-gray-400 has-[input[data-focused=true]]:ring-2 has-[input[data-focused=true]]:ring-inset has-[input[data-focused=true]]:ring-emerald-600"> <TagList items={list.items}> {(item) => <Tag>{item.name}</Tag>} </TagList>
<AriaInput className="min-w-0 grow border-none px-2 py-1 text-xs font-medium text-gray-900 outline-none placeholder:text-gray-400 focus:outline-none focus:ring-0" aria-labelledby={labelId} onKeyDown={handleKeyDown} placeholder="Enter tag" ref={inputRef} /> </div> </div> </AriaTagGroup> );}
function TagList<T extends TagItem>({ children, items, ...props}: Omit<AriaTagListProps<T>, 'className'>) { return ( <AriaTagList className={clsx( 'flex flex-wrap gap-1.5', (items as TagItem[]).length === 0 && 'hidden', )} items={items} {...props} > {children} </AriaTagList> );}
function Tag({ children, color = 'emerald', id, ...props }: TagProps) { const colors = { gray: "[--bg-color:theme('colors.gray.50')] [--ring-color:theme('colors.gray.500/10%')] [--text-color:theme('colors.gray.600')]", emerald: "[--bg-color:theme('colors.emerald.50')] [--ring-color:theme('colors.emerald.500/10%')] [--text-color:theme('colors.emerald.600')]", }[color]; const textValue = typeof children === 'string' ? children : undefined;
return ( <AriaTag className={clsx( colors, 'group relative flex items-center truncate rounded-md bg-[--bg-color] px-2 py-1 text-xs font-medium text-[--text-color] ring-1 ring-inset ring-[--ring-color] transition-shadow', 'data-[focused]:outline-none data-[focused]:ring-2 data-[focused]:ring-inset data-[focused]:ring-[--text-color]', )} id={id} textValue={textValue} {...props} > {({ allowsRemoving }) => ( <> {children} {allowsRemoving && ( <div className="absolute inset-y-0.5 end-0.5 flex w-full min-w-6 max-w-12 items-center justify-end rounded bg-gradient-to-l from-[--bg-color] from-50% opacity-0 transition-opacity group-hover:opacity-100 group-data-[focused]:opacity-100"> <AriaButton className="grid size-5 place-items-center" slot="remove" > <XMarkIcon className="size-4" /> </AriaButton> </div> )} </> )} </AriaTag> );}