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).
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.
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 };/** 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';---
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>// 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 {};/** 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';<div class="grid gap-3">
<ul class="m-0 flex list-none flex-wrap gap-2 p-0" aria-label="Topics">
<li>
<a href="#" class="inline-flex h-7 items-center gap-1 rounded-full border bg-background px-3 text-[13px] font-medium whitespace-nowrap transition-colors hover:bg-accent active:bg-pressed">
Deploys
</a>
</li>
<li>
<a href="#" class="inline-flex h-7 items-center gap-1 rounded-full border bg-background px-3 text-[13px] font-medium whitespace-nowrap transition-colors hover:bg-accent active:bg-pressed">
Domains
</a>
</li>
<li>
<a href="#" class="inline-flex h-7 items-center gap-1 rounded-full border bg-background px-3 text-[13px] font-medium whitespace-nowrap transition-colors hover:bg-accent active:bg-pressed">
CLI
</a>
</li>
</ul>
<ul class="m-0 flex list-none flex-wrap gap-2 p-0" aria-label="Active filters">
<li>
<span class="inline-flex h-7 items-center gap-1 rounded-full border bg-background px-3 text-[13px] font-medium whitespace-nowrap pr-1">
Live
<button type="button" data-tag-remove data-value="Live" aria-label="Remove Live" class="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">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"></path></svg>
</button>
</span>
</li>
<li>
<span class="inline-flex h-7 items-center gap-1 rounded-full border bg-background px-3 text-[13px] font-medium whitespace-nowrap pr-1">
main
<button type="button" data-tag-remove data-value="main" aria-label="Remove main" class="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">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"></path></svg>
</button>
</span>
</li>
<li>
<span class="inline-flex h-7 items-center gap-1 rounded-full border bg-background px-3 text-[13px] font-medium whitespace-nowrap pr-1">
This week
<button type="button" data-tag-remove data-value="This week" aria-label="Remove This week" class="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">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"></path></svg>
</button>
</span>
</li>
</ul>
</div><script type="module" src="/caseroom/r/html/tag.js"></script>