Skip to content
    Style: Wireframe

    Components · Display

    Tag

    A topic or an active filter: static, a link, or removable with focus moving to the next tag.

    1.4% of the 2,073 sites in The UI Index have a tag (28 sites).

    Used in UI Pro: Case header, Careers

    • Deploys
    • Domains
    • CLI
    • Live
    • main
    • This week

    Both previews render the same markup; the HTML tab below is that markup without a framework.

    Install

    React
    npx shadcn@latest add https://www.themes.dev/ui/r/tag.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/tag.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI theme. The HTML needs only the compiled CSS and the script.

    tag.tsx
    import { cn } from '@/lib/utils';
    import { REMOVE_PATH, tag } from './tag.variants';
    
    export interface TagProps {
      label: string;
      href?: string;
      /** Called when the remove button is pressed (shows the button). */
      onRemove?: () => void;
      className?: string;
    }
    
    /** A tag: static, a link, or removable (its button is named "Remove <label>"). */
    export function Tag({ label, href, onRemove, className }: TagProps) {
      if (href) return <a href={href} className={cn(tag.root, tag.link, className)}>{label}</a>;
      return (
        <span className={cn(tag.root, onRemove && tag.removable, className)}>
          {label}
          {onRemove && (
            <button
              type="button" data-tag-remove="" data-value={label} aria-label={`Remove ${label}`} className={tag.remove}
              onClick={(e) => {
                // Handled here; the delegated script for Astro and HTML tags leaves it alone.
                e.preventDefault();
                const next = e.currentTarget.closest('li')?.nextElementSibling?.querySelector<HTMLElement>('[data-tag-remove], a') ?? e.currentTarget.closest('li')?.previousElementSibling?.querySelector<HTMLElement>('[data-tag-remove], a');
                onRemove();
                next?.focus();
              }}
            >
              <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true"><path d={REMOVE_PATH} /></svg>
            </button>
          )}
        </span>
      );
    }
    export { tag as tagClasses };
    tag.variants.ts
    /** A tag: a short label for a topic or an active filter. Static, a link, or removable (a button with "Remove <tag>"
     *  as its name; removing it moves focus to the next tag). Taller than a badge, so it is a 24px target. */
    export const tag = {
      root: 'inline-flex h-7 items-center gap-1 rounded-full border bg-background px-3 text-[13px] font-medium whitespace-nowrap',
      link: 'transition-colors hover:bg-accent active:bg-pressed',
      removable: 'pr-1',
      remove: 'ml-0.5 inline-flex size-5 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed [&_svg]:size-3',
      group: 'm-0 flex list-none flex-wrap gap-2 p-0',
    };
    export const REMOVE_PATH = 'M4 4l8 8M12 4l-8 8';