Skip to content
Caseroom

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,062 sites in The UI Index have a tag (28 sites).

  • 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/caseroom/r/tag.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/tag.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom 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';