Back

Inline Tag Group

A tag group with the look and feel of an input.

Tags
react-aria-components
tailwind-css

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>
);
}