# Tag (Caseroom component)

A topic or an active filter: static, a link, or removable with focus moving to the next tag. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

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

## 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`
- HTML: https://www.themes.dev/caseroom/r/html/tag.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/tag/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### tag.astro

```astro
---
import { cn } from '@/lib/utils';
import { REMOVE_PATH, tag } from './tag.variants';

interface Props {
  label: string;
  href?: string;
  /** Show a remove button; its value comes back in the tag-remove event. */
  removable?: boolean;
  value?: string;
  class?: string;
}
const { label, href, removable, value = label, class: className } = Astro.props;
---

{
  href ? (
    <a href={href} class={cn(tag.root, tag.link, className)}>{label}</a>
  ) : (
    <span class={cn(tag.root, removable && tag.removable, className)}>
      {label}
      {removable && (
        <button type="button" data-tag-remove data-value={value} aria-label={`Remove ${label}`} class={tag.remove}>
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d={REMOVE_PATH} /></svg>
        </button>
      )}
    </span>
  )
}

<script>
  import './tag.client';
</script>
```

### tag.client.ts

```tsx
// Removable tags for Astro and plain HTML: a click on [data-tag-remove] removes its tag, fires a bubbling
// `tag-remove` event ({ value }) and moves focus to the next tag's remove button (or the previous one, or the list).
declare global {
  interface Window {
    __caseroomTag?: boolean;
  }
}

if (typeof window !== 'undefined' && !window.__caseroomTag) {
  window.__caseroomTag = true;
  document.addEventListener('click', (e) => {
    const button = (e.target as Element).closest<HTMLButtonElement>('[data-tag-remove]');
    if (!button || e.defaultPrevented) return;
    const item = button.closest('li') ?? button.parentElement!;
    const list = item.parentElement!;
    const next = (item.nextElementSibling ?? item.previousElementSibling)?.querySelector<HTMLElement>('[data-tag-remove], a');
    list.dispatchEvent(new CustomEvent('tag-remove', { bubbles: true, detail: { value: button.dataset.value } }));
    item.remove();
    (next ?? list.closest<HTMLElement>('[tabindex]') ?? (list as HTMLElement)).focus?.();
  });
}

export {};
```

### tag.tsx

```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

```tsx
/** 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';
```
