Tag
A compact label for categorizing content, with optional icon, prefix, or avatar.
npx shadcn@latest add @velaui/tagpnpm dlx velaui add @velaui/tagyarn dlx shadcn@latest add @velaui/tagbunx --bun shadcn@latest add @velaui/tagveni add tagInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/tagpnpm dlx velaui add @velaui/tagyarn dlx shadcn@latest add @velaui/tagbunx --bun shadcn@latest add @velaui/tagExamples
- Default
- With icon
- With prefix
- Selected
- With avatar
- Disabled
Usage
Props
Tag
| Prop | Type | Default |
|---|---|---|
asChild | boolean | false |
disabled | boolean | false |
selected | boolean | false |
size | "sm" | "md" | "sm" |
Takes these and every prop of TagProps.
TagIcon
Takes every prop of TagIconProps.
TagPrefix
Takes every prop of TagPrefixProps.
TagClose
Takes every prop of TagCloseProps.
Tag
| Prop | Type | Default |
|---|---|---|
disabled | boolean | false |
selected | boolean | false |
size | "sm" | "md" | "sm" |
Takes these and every prop of TagProps.
TagIcon
Takes every prop of TagIconProps.
TagPrefix
Takes every prop of TagPrefixProps.
TagClose
Takes every prop of TagCloseProps.
Dependencies
- npm
class-variance-authority@0.7.1radix-ui@1.6.0- Installs with it
- 1 icon
- npm
@base-ui/react@1.6.0class-variance-authority@0.7.1- Installs with it
- 1 icon
Source
components/ui/tag.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Slot } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { Close02 } from "@/components/icons/close-02";
import { cn } from "@/lib/utils";
const tagVariants = cva(
[
"group/tag inline-flex h-6 w-fit items-center gap-2 overflow-hidden rounded-lg bg-bg-surface-1 px-2 py-0.5 text-sm leading-[1.4] font-medium text-text-strong transition-colors",
// A leading icon or avatar tightens the left padding so the glyph keeps the
// same optical inset as a bare label.
"has-data-[slot=tag-icon]:pl-1 has-data-[slot=avatar]:pl-0.5",
"hover:bg-bg-surface-2",
// Focus draws a 3px surface-2 halo, selected a 1px inner rule. Both are
// ring/outline rather than a border so entering either never reflows the
// row (and so the two can coexist, which the design never renders).
"focus-visible:ring-3 focus-visible:ring-bg-surface-2",
"data-[state=selected]:outline-1 data-[state=selected]:-outline-offset-1 data-[state=selected]:outline-border-active",
// The tag renders as a span, so `disabled:` never matches — the disabled
// state hangs off aria-disabled, which Tag always writes.
"aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:text-text-disabled",
],
{
variants: {
// Size changes nothing but the prefix's type scale — the label, the box
// and the padding are identical across both sizes in the design.
size: {
sm: "[&_[data-slot=tag-prefix]]:text-sm [&_[data-slot=tag-prefix]]:leading-5 [&_[data-slot=tag-prefix]]:tracking-[-0.2px]",
md: "[&_[data-slot=tag-prefix]]:text-base [&_[data-slot=tag-prefix]]:leading-6 [&_[data-slot=tag-prefix]]:tracking-[-0.24px]",
},
},
defaultVariants: {
size: "sm",
},
},
);
export type TagProps = Omit<React.ComponentProps<"span">, "size"> &
VariantProps<typeof tagVariants> & {
asChild?: boolean;
selected?: boolean;
disabled?: boolean;
};
function Tag({
className,
size = "sm",
asChild = false,
selected = false,
disabled = false,
...props
}: TagProps) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="tag"
data-size={size}
data-state={selected ? "selected" : undefined}
aria-disabled={disabled || undefined}
className={cn(tagVariants({ size }), className)}
{...props}
/>
);
}
export type TagIconProps = React.ComponentProps<"span">;
function TagIcon({ className, ...props }: TagIconProps) {
return (
<span
data-slot="tag-icon"
className={cn(
"inline-flex size-4 shrink-0 items-center justify-center text-icon-strong group-aria-disabled/tag:text-icon-disabled [&>svg]:size-4",
className,
)}
{...props}
/>
);
}
export type TagPrefixProps = React.ComponentProps<"span">;
function TagPrefix({ className, ...props }: TagPrefixProps) {
return (
<span
data-slot="tag-prefix"
className={cn(
"shrink-0 font-normal text-text-muted group-aria-disabled/tag:text-text-disabled",
className,
)}
{...props}
/>
);
}
export type TagCloseProps = React.ComponentProps<"button">;
function TagClose({
className,
children,
...props
}: TagCloseProps) {
return (
<button
type="button"
data-slot="tag-close"
className={cn(
"inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-icon-note transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow group-aria-disabled/tag:pointer-events-none group-aria-disabled/tag:text-icon-disabled [&>svg]:size-4",
className,
)}
{...props}
>
{children ?? <Close02 aria-hidden="true" />}
<span className="sr-only">Remove</span>
</button>
);
}
export { Tag, TagIcon, TagPrefix, TagClose, tagVariants };components/ui/tag.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { Close02 } from "@/components/icons/close-02";
import { cn } from "@/lib/utils";
const tagVariants = cva(
[
"group/tag inline-flex h-6 w-fit items-center gap-2 overflow-hidden rounded-lg bg-bg-surface-1 px-2 py-0.5 text-sm leading-[1.4] font-medium text-text-strong transition-colors",
// A leading icon or avatar tightens the left padding so the glyph keeps the
// same optical inset as a bare label.
"has-data-[slot=tag-icon]:pl-1 has-data-[slot=avatar]:pl-0.5",
"hover:bg-bg-surface-2",
// Focus draws a 3px surface-2 halo, selected a 1px inner rule. Both are
// ring/outline rather than a border so entering either never reflows the
// row (and so the two can coexist, which the design never renders).
"focus-visible:ring-3 focus-visible:ring-bg-surface-2",
"data-[state=selected]:outline-1 data-[state=selected]:-outline-offset-1 data-[state=selected]:outline-border-active",
// The tag renders as a span, so `disabled:` never matches — the disabled
// state hangs off aria-disabled, which Tag always writes.
"aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:text-text-disabled",
],
{
variants: {
// Size changes nothing but the prefix's type scale — the label, the box
// and the padding are identical across both sizes in the design.
size: {
sm: "[&_[data-slot=tag-prefix]]:text-sm [&_[data-slot=tag-prefix]]:leading-5 [&_[data-slot=tag-prefix]]:tracking-[-0.2px]",
md: "[&_[data-slot=tag-prefix]]:text-base [&_[data-slot=tag-prefix]]:leading-6 [&_[data-slot=tag-prefix]]:tracking-[-0.24px]",
},
},
defaultVariants: {
size: "sm",
},
},
);
export type TagProps = Omit<useRender.ComponentProps<"span">, "size"> &
VariantProps<typeof tagVariants> & {
selected?: boolean;
disabled?: boolean;
};
function Tag({
className,
size = "sm",
selected = false,
disabled = false,
render,
...props
}: TagProps) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(tagVariants({ size }), className),
"aria-disabled": disabled || undefined,
},
props,
),
render,
state: {
slot: "tag",
size,
state: selected ? "selected" : undefined,
},
});
}
export type TagIconProps = React.ComponentProps<"span">;
function TagIcon({ className, ...props }: TagIconProps) {
return (
<span
data-slot="tag-icon"
className={cn(
"inline-flex size-4 shrink-0 items-center justify-center text-icon-strong group-aria-disabled/tag:text-icon-disabled [&>svg]:size-4",
className,
)}
{...props}
/>
);
}
export type TagPrefixProps = React.ComponentProps<"span">;
function TagPrefix({ className, ...props }: TagPrefixProps) {
return (
<span
data-slot="tag-prefix"
className={cn(
"shrink-0 font-normal text-text-muted group-aria-disabled/tag:text-text-disabled",
className,
)}
{...props}
/>
);
}
export type TagCloseProps = React.ComponentProps<"button">;
function TagClose({
className,
children,
...props
}: TagCloseProps) {
return (
<button
type="button"
data-slot="tag-close"
className={cn(
"inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-icon-note transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow group-aria-disabled/tag:pointer-events-none group-aria-disabled/tag:text-icon-disabled [&>svg]:size-4",
className,
)}
{...props}
>
{children ?? <Close02 aria-hidden="true" />}
<span className="sr-only">Remove</span>
</button>
);
}
export { Tag, TagIcon, TagPrefix, TagClose, tagVariants };