Tags
Interactive tag selection component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring search filtering, keyboard navigation, and controlled state management.
'use client';import { Tags, TagsContent, TagsEmpty, TagsGroup, TagsInput, TagsItem, TagsList, TagsTrigger, TagsValue,} from '@/components/ui/shadcn-io/tags';import { CheckIcon } from 'lucide-react';import { useState } from 'react';const tags = [ { id: 'react', label: 'React' }, { id: 'typescript', label: 'TypeScript' }, { id: 'javascript', label: 'JavaScript' }, { id: 'nextjs', label: 'Next.js' }, { id: 'vuejs', label: 'Vue.js' }, { id: 'angular', label: 'Angular' }, { id: 'svelte', label: 'Svelte' }, { id: 'nodejs', label: 'Node.js' }, { id: 'python', label: 'Python' }, { id: 'ruby', label: 'Ruby' }, { id: 'java', label: 'Java' }, { id: 'csharp', label: 'C#' }, { id: 'php', label: 'PHP' }, { id: 'go', label: 'Go' },];const Example = () => { const [selected, setSelected] = useState<string[]>([]); const handleRemove = (value: string) => { if (!selected.includes(value)) { return; } console.log(`removed: ${value}`); setSelected((prev) => prev.filter((v) => v !== value)); }; const handleSelect = (value: string) => { if (selected.includes(value)) { handleRemove(value); return; } console.log(`selected: ${value}`); setSelected((prev) => [...prev, value]); }; return ( <Tags className="max-w-[300px]"> <TagsTrigger> {selected.map((tag) => ( <TagsValue key={tag} onRemove={() => handleRemove(tag)}> {tags.find((t) => t.id === tag)?.label} </TagsValue> ))} </TagsTrigger> <TagsContent> <TagsInput placeholder="Search tag..." /> <TagsList> <TagsEmpty /> <TagsGroup> {tags.map((tag) => ( <TagsItem key={tag.id} onSelect={handleSelect} value={tag.id}> {tag.label} {selected.includes(tag.id) && ( <CheckIcon className="text-muted-foreground" size={14} /> )} </TagsItem> ))} </TagsGroup> </TagsList> </TagsContent> </Tags> );};export default Example;
'use client';import { XIcon } from 'lucide-react';import { type ComponentProps, createContext, type MouseEventHandler, type ReactNode, useContext, useEffect, useRef, useState,} from 'react';import { Badge } from '@/components/ui/badge';import { Button } from '@/components/ui/button';import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from '@/components/ui/command';import { Popover, PopoverContent, PopoverTrigger,} from '@/components/ui/popover';import { cn } from '@/lib/utils';type TagsContextType = { value?: string; setValue?: (value: string) => void; open: boolean; onOpenChange: (open: boolean) => void; width?: number; setWidth?: (width: number) => void;};const TagsContext = createContext<TagsContextType>({ value: undefined, setValue: undefined, open: false, onOpenChange: () => { }, width: undefined, setWidth: undefined,});const useTagsContext = () => { const context = useContext(TagsContext); if (!context) { throw new Error('useTagsContext must be used within a TagsProvider'); } return context;};export type TagsProps = { value?: string; setValue?: (value: string) => void; open?: boolean; onOpenChange?: (open: boolean) => void; children?: ReactNode; className?: string;};export const Tags = ({ value, setValue, open: controlledOpen, onOpenChange: controlledOnOpenChange, children, className,}: TagsProps) => { const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const [width, setWidth] = useState<number>(); const ref = useRef<HTMLDivElement>(null); const open = controlledOpen ?? uncontrolledOpen; const onOpenChange = controlledOnOpenChange ?? setUncontrolledOpen; useEffect(() => { if (!ref.current) { return; } const resizeObserver = new ResizeObserver((entries) => { setWidth(entries[0].contentRect.width); }); resizeObserver.observe(ref.current); return () => { resizeObserver.disconnect(); }; }, []); return ( <TagsContext.Provider value={{ value, setValue, open, onOpenChange, width, setWidth }} > <Popover onOpenChange={onOpenChange} open={open}> <div className={cn('relative w-full', className)} ref={ref}> {children} </div> </Popover> </TagsContext.Provider> );};export type TagsTriggerProps = ComponentProps<typeof Button>;export const TagsTrigger = ({ className, children, ...props}: TagsTriggerProps) => ( <PopoverTrigger asChild> <Button className={cn('h-auto w-full justify-between p-2', className)} // biome-ignore lint/a11y/useSemanticElements: "Required" role="combobox" variant="outline" {...props} > <div className="flex flex-wrap items-center gap-1"> {children} <span className="px-2 py-px text-muted-foreground"> Select a tag... </span> </div> </Button> </PopoverTrigger>);export type TagsValueProps = ComponentProps<typeof Badge>;export const TagsValue = ({ className, children, onRemove, ...props}: TagsValueProps & { onRemove?: () => void }) => { const handleRemove: MouseEventHandler<HTMLDivElement> = (event) => { event.preventDefault(); event.stopPropagation(); onRemove?.(); }; return ( <Badge className={cn('flex items-center gap-2', className)} {...props}> {children} {onRemove && ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable badge" // biome-ignore lint/a11y/useKeyWithClickEvents: "This is a clickable badge" <div className="size-auto cursor-pointer hover:text-muted-foreground" onClick={handleRemove} > <XIcon size={12} /> </div> )} </Badge> );};export type TagsContentProps = ComponentProps<typeof PopoverContent>;export const TagsContent = ({ className, children, ...props}: TagsContentProps) => { const { width } = useTagsContext(); return ( <PopoverContent className={cn('p-0', className)} style={{ width }} {...props} > <Command>{children}</Command> </PopoverContent> );};export type TagsInputProps = ComponentProps<typeof CommandInput>;export const TagsInput = ({ className, ...props }: TagsInputProps) => ( <CommandInput className={cn('h-9', className)} {...props} />);export type TagsListProps = ComponentProps<typeof CommandList>;export const TagsList = ({ className, ...props }: TagsListProps) => ( <CommandList className={cn('max-h-[200px]', className)} {...props} />);export type TagsEmptyProps = ComponentProps<typeof CommandEmpty>;export const TagsEmpty = ({ children, className, ...props}: TagsEmptyProps) => ( <CommandEmpty {...props}>{children ?? 'No tags found.'}</CommandEmpty>);export type TagsGroupProps = ComponentProps<typeof CommandGroup>;export const TagsGroup = CommandGroup;export type TagsItemProps = ComponentProps<typeof CommandItem>;export const TagsItem = ({ className, ...props }: TagsItemProps) => ( <CommandItem className={cn('cursor-pointer items-center justify-between', className)} {...props} />);
Installation
npx shadcn@latest add https://www.shadcn.io/registry/tags.json
npx shadcn@latest add https://www.shadcn.io/registry/tags.json
pnpm dlx shadcn@latest add https://www.shadcn.io/registry/tags.json
bunx shadcn@latest add https://www.shadcn.io/registry/tags.json
Features
- Search filtering - Built-in input field for tag discovery using JavaScript text matching
- Controlled/uncontrolled - Flexible state management options for React applications
- Keyboard navigation - Full Command component integration with arrow key support using TypeScript handlers
- Responsive width - Auto-adjusting container sizing based on parent element for Next.js projects
- Removable tags - Optional dismiss buttons for selected items using event callbacks
- Context management - Shared state through TagsContext for component communication using React Context
- Customizable styling - Complete appearance control with className props using Tailwind CSS
- Open source - Free tag component with shadcn/ui theming and accessibility support
Examples
Create a tag
'use client';import { Tags, TagsContent, TagsEmpty, TagsGroup, TagsInput, TagsItem, TagsList, TagsTrigger, TagsValue,} from '@/components/ui/shadcn-io/tags';import { CheckIcon, PlusIcon } from 'lucide-react';import { useState } from 'react';const defaultTags = [ { id: 'react', label: 'React' }, { id: 'typescript', label: 'TypeScript' }, { id: 'javascript', label: 'JavaScript' }, { id: 'nextjs', label: 'Next.js' }, { id: 'vuejs', label: 'Vue.js' }, { id: 'angular', label: 'Angular' }, { id: 'svelte', label: 'Svelte' }, { id: 'nodejs', label: 'Node.js' }, { id: 'python', label: 'Python' }, { id: 'ruby', label: 'Ruby' }, { id: 'java', label: 'Java' }, { id: 'csharp', label: 'C#' }, { id: 'php', label: 'PHP' }, { id: 'go', label: 'Go' },];const Example = () => { const [selected, setSelected] = useState<string[]>([]); const [newTag, setNewTag] = useState<string>(''); const [tags, setTags] = useState<{ id: string; label: string }[]>(defaultTags); const handleRemove = (value: string) => { if (!selected.includes(value)) { return; } console.log(`removed: ${value}`); setSelected((prev) => prev.filter((v) => v !== value)); }; const handleSelect = (value: string) => { if (selected.includes(value)) { handleRemove(value); return; } console.log(`selected: ${value}`); setSelected((prev) => [...prev, value]); }; const handleCreateTag = () => { console.log(`created: ${newTag}`); setTags((prev) => [ ...prev, { id: newTag, label: newTag, }, ]); setSelected((prev) => [...prev, newTag]); setNewTag(''); }; return ( <Tags className="max-w-[300px]"> <TagsTrigger> {selected.map((tag) => ( <TagsValue key={tag} onRemove={() => handleRemove(tag)}> {tags.find((t) => t.id === tag)?.label} </TagsValue> ))} </TagsTrigger> <TagsContent> <TagsInput onValueChange={setNewTag} placeholder="Search tag..." /> <TagsList> <TagsEmpty> <button className="mx-auto flex cursor-pointer items-center gap-2" onClick={handleCreateTag} type="button" > <PlusIcon className="text-muted-foreground" size={14} /> Create new tag: {newTag} </button> </TagsEmpty> <TagsGroup> {tags.map((tag) => ( <TagsItem key={tag.id} onSelect={handleSelect} value={tag.id}> {tag.label} {selected.includes(tag.id) && ( <CheckIcon className="text-muted-foreground" size={14} /> )} </TagsItem> ))} </TagsGroup> </TagsList> </TagsContent> </Tags> );};export default Example;
'use client';import { XIcon } from 'lucide-react';import { type ComponentProps, createContext, type MouseEventHandler, type ReactNode, useContext, useEffect, useRef, useState,} from 'react';import { Badge } from '@/components/ui/badge';import { Button } from '@/components/ui/button';import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from '@/components/ui/command';import { Popover, PopoverContent, PopoverTrigger,} from '@/components/ui/popover';import { cn } from '@/lib/utils';type TagsContextType = { value?: string; setValue?: (value: string) => void; open: boolean; onOpenChange: (open: boolean) => void; width?: number; setWidth?: (width: number) => void;};const TagsContext = createContext<TagsContextType>({ value: undefined, setValue: undefined, open: false, onOpenChange: () => { }, width: undefined, setWidth: undefined,});const useTagsContext = () => { const context = useContext(TagsContext); if (!context) { throw new Error('useTagsContext must be used within a TagsProvider'); } return context;};export type TagsProps = { value?: string; setValue?: (value: string) => void; open?: boolean; onOpenChange?: (open: boolean) => void; children?: ReactNode; className?: string;};export const Tags = ({ value, setValue, open: controlledOpen, onOpenChange: controlledOnOpenChange, children, className,}: TagsProps) => { const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const [width, setWidth] = useState<number>(); const ref = useRef<HTMLDivElement>(null); const open = controlledOpen ?? uncontrolledOpen; const onOpenChange = controlledOnOpenChange ?? setUncontrolledOpen; useEffect(() => { if (!ref.current) { return; } const resizeObserver = new ResizeObserver((entries) => { setWidth(entries[0].contentRect.width); }); resizeObserver.observe(ref.current); return () => { resizeObserver.disconnect(); }; }, []); return ( <TagsContext.Provider value={{ value, setValue, open, onOpenChange, width, setWidth }} > <Popover onOpenChange={onOpenChange} open={open}> <div className={cn('relative w-full', className)} ref={ref}> {children} </div> </Popover> </TagsContext.Provider> );};export type TagsTriggerProps = ComponentProps<typeof Button>;export const TagsTrigger = ({ className, children, ...props}: TagsTriggerProps) => ( <PopoverTrigger asChild> <Button className={cn('h-auto w-full justify-between p-2', className)} // biome-ignore lint/a11y/useSemanticElements: "Required" role="combobox" variant="outline" {...props} > <div className="flex flex-wrap items-center gap-1"> {children} <span className="px-2 py-px text-muted-foreground"> Select a tag... </span> </div> </Button> </PopoverTrigger>);export type TagsValueProps = ComponentProps<typeof Badge>;export const TagsValue = ({ className, children, onRemove, ...props}: TagsValueProps & { onRemove?: () => void }) => { const handleRemove: MouseEventHandler<HTMLDivElement> = (event) => { event.preventDefault(); event.stopPropagation(); onRemove?.(); }; return ( <Badge className={cn('flex items-center gap-2', className)} {...props}> {children} {onRemove && ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable badge" // biome-ignore lint/a11y/useKeyWithClickEvents: "This is a clickable badge" <div className="size-auto cursor-pointer hover:text-muted-foreground" onClick={handleRemove} > <XIcon size={12} /> </div> )} </Badge> );};export type TagsContentProps = ComponentProps<typeof PopoverContent>;export const TagsContent = ({ className, children, ...props}: TagsContentProps) => { const { width } = useTagsContext(); return ( <PopoverContent className={cn('p-0', className)} style={{ width }} {...props} > <Command>{children}</Command> </PopoverContent> );};export type TagsInputProps = ComponentProps<typeof CommandInput>;export const TagsInput = ({ className, ...props }: TagsInputProps) => ( <CommandInput className={cn('h-9', className)} {...props} />);export type TagsListProps = ComponentProps<typeof CommandList>;export const TagsList = ({ className, ...props }: TagsListProps) => ( <CommandList className={cn('max-h-[200px]', className)} {...props} />);export type TagsEmptyProps = ComponentProps<typeof CommandEmpty>;export const TagsEmpty = ({ children, className, ...props}: TagsEmptyProps) => ( <CommandEmpty {...props}>{children ?? 'No tags found.'}</CommandEmpty>);export type TagsGroupProps = ComponentProps<typeof CommandGroup>;export const TagsGroup = CommandGroup;export type TagsItemProps = ComponentProps<typeof CommandItem>;export const TagsItem = ({ className, ...props }: TagsItemProps) => ( <CommandItem className={cn('cursor-pointer items-center justify-between', className)} {...props} />);
Filter available tags
'use client';import { Tags, TagsContent, TagsEmpty, TagsGroup, TagsInput, TagsItem, TagsList, TagsTrigger, TagsValue,} from '@/components/ui/shadcn-io/tags';import { useState } from 'react';const defaultTags = [ { id: 'react', label: 'React' }, { id: 'typescript', label: 'TypeScript' }, { id: 'javascript', label: 'JavaScript' }, { id: 'nextjs', label: 'Next.js' }, { id: 'vuejs', label: 'Vue.js' }, { id: 'angular', label: 'Angular' }, { id: 'svelte', label: 'Svelte' }, { id: 'nodejs', label: 'Node.js' }, { id: 'python', label: 'Python' }, { id: 'ruby', label: 'Ruby' }, { id: 'java', label: 'Java' }, { id: 'csharp', label: 'C#' }, { id: 'php', label: 'PHP' }, { id: 'go', label: 'Go' },];const Example = () => { const [selected, setSelected] = useState<string[]>([]); const [newTag, setNewTag] = useState<string>(''); const [tags, setTags] = useState<{ id: string; label: string }[]>(defaultTags); const handleRemove = (value: string) => { if (!selected.includes(value)) { return; } console.log(`removed: ${value}`); setSelected((prev) => prev.filter((v) => v !== value)); }; const handleSelect = (value: string) => { if (selected.includes(value)) { handleRemove(value); return; } console.log(`selected: ${value}`); setSelected((prev) => [...prev, value]); }; return ( <Tags className="max-w-[300px]"> <TagsTrigger> {selected.map((tag) => ( <TagsValue key={tag} onRemove={() => handleRemove(tag)}> {tags.find((t) => t.id === tag)?.label} </TagsValue> ))} </TagsTrigger> <TagsContent> <TagsInput onValueChange={setNewTag} placeholder="Search tag..." /> <TagsList> <TagsEmpty /> <TagsGroup> {tags .filter((tag) => !selected.includes(tag.id)) .map((tag) => ( <TagsItem key={tag.id} onSelect={handleSelect} value={tag.id}> {tag.label} </TagsItem> ))} </TagsGroup> </TagsList> </TagsContent> </Tags> );};export default Example;
'use client';import { XIcon } from 'lucide-react';import { type ComponentProps, createContext, type MouseEventHandler, type ReactNode, useContext, useEffect, useRef, useState,} from 'react';import { Badge } from '@/components/ui/badge';import { Button } from '@/components/ui/button';import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from '@/components/ui/command';import { Popover, PopoverContent, PopoverTrigger,} from '@/components/ui/popover';import { cn } from '@/lib/utils';type TagsContextType = { value?: string; setValue?: (value: string) => void; open: boolean; onOpenChange: (open: boolean) => void; width?: number; setWidth?: (width: number) => void;};const TagsContext = createContext<TagsContextType>({ value: undefined, setValue: undefined, open: false, onOpenChange: () => { }, width: undefined, setWidth: undefined,});const useTagsContext = () => { const context = useContext(TagsContext); if (!context) { throw new Error('useTagsContext must be used within a TagsProvider'); } return context;};export type TagsProps = { value?: string; setValue?: (value: string) => void; open?: boolean; onOpenChange?: (open: boolean) => void; children?: ReactNode; className?: string;};export const Tags = ({ value, setValue, open: controlledOpen, onOpenChange: controlledOnOpenChange, children, className,}: TagsProps) => { const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const [width, setWidth] = useState<number>(); const ref = useRef<HTMLDivElement>(null); const open = controlledOpen ?? uncontrolledOpen; const onOpenChange = controlledOnOpenChange ?? setUncontrolledOpen; useEffect(() => { if (!ref.current) { return; } const resizeObserver = new ResizeObserver((entries) => { setWidth(entries[0].contentRect.width); }); resizeObserver.observe(ref.current); return () => { resizeObserver.disconnect(); }; }, []); return ( <TagsContext.Provider value={{ value, setValue, open, onOpenChange, width, setWidth }} > <Popover onOpenChange={onOpenChange} open={open}> <div className={cn('relative w-full', className)} ref={ref}> {children} </div> </Popover> </TagsContext.Provider> );};export type TagsTriggerProps = ComponentProps<typeof Button>;export const TagsTrigger = ({ className, children, ...props}: TagsTriggerProps) => ( <PopoverTrigger asChild> <Button className={cn('h-auto w-full justify-between p-2', className)} // biome-ignore lint/a11y/useSemanticElements: "Required" role="combobox" variant="outline" {...props} > <div className="flex flex-wrap items-center gap-1"> {children} <span className="px-2 py-px text-muted-foreground"> Select a tag... </span> </div> </Button> </PopoverTrigger>);export type TagsValueProps = ComponentProps<typeof Badge>;export const TagsValue = ({ className, children, onRemove, ...props}: TagsValueProps & { onRemove?: () => void }) => { const handleRemove: MouseEventHandler<HTMLDivElement> = (event) => { event.preventDefault(); event.stopPropagation(); onRemove?.(); }; return ( <Badge className={cn('flex items-center gap-2', className)} {...props}> {children} {onRemove && ( // biome-ignore lint/a11y/noStaticElementInteractions: "This is a clickable badge" // biome-ignore lint/a11y/useKeyWithClickEvents: "This is a clickable badge" <div className="size-auto cursor-pointer hover:text-muted-foreground" onClick={handleRemove} > <XIcon size={12} /> </div> )} </Badge> );};export type TagsContentProps = ComponentProps<typeof PopoverContent>;export const TagsContent = ({ className, children, ...props}: TagsContentProps) => { const { width } = useTagsContext(); return ( <PopoverContent className={cn('p-0', className)} style={{ width }} {...props} > <Command>{children}</Command> </PopoverContent> );};export type TagsInputProps = ComponentProps<typeof CommandInput>;export const TagsInput = ({ className, ...props }: TagsInputProps) => ( <CommandInput className={cn('h-9', className)} {...props} />);export type TagsListProps = ComponentProps<typeof CommandList>;export const TagsList = ({ className, ...props }: TagsListProps) => ( <CommandList className={cn('max-h-[200px]', className)} {...props} />);export type TagsEmptyProps = ComponentProps<typeof CommandEmpty>;export const TagsEmpty = ({ children, className, ...props}: TagsEmptyProps) => ( <CommandEmpty {...props}>{children ?? 'No tags found.'}</CommandEmpty>);export type TagsGroupProps = ComponentProps<typeof CommandGroup>;export const TagsGroup = CommandGroup;export type TagsItemProps = ComponentProps<typeof CommandItem>;export const TagsItem = ({ className, ...props }: TagsItemProps) => ( <CommandItem className={cn('cursor-pointer items-center justify-between', className)} {...props} />);
Use Cases
- Content tagging - Blog posts, articles, and media categorization systems
- User profiles - Skill tags, interests, and preference selection interfaces
- Product filters - E-commerce category and attribute filtering
- Project organization - Task labeling and team collaboration tools
Implementation
Built with Command component for search and navigation. Uses TagsContext for state sharing. Supports tag creation and removal. Keyboard accessible with full ARIA support.
Pill
Flexible badge pill component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring status indicators, avatar integration, and customizable variants.
Simple Navigation Bar
Clean and modern navbar with mobile responsive menu. Perfect for React applications requiring professional navigation with Next.js integration and TypeScript support.