Vela UI Docsvenizia.ai

Tag

A compact label for categorizing content, with optional icon, prefix, or avatar.

npx shadcn@latest add @velaui/tag
npx shadcn@latest add @velaui/tag

Examples

  • Default
  • With icon
  • With prefix
  • Selected
  • With avatar
  • Disabled

View code

Tag · Default

Installation

pnpm dlx velaui add @velaui/tag
pnpm dlx velaui add @velaui/tag

Code

tag-default.tsx
import { Tag } from "@/components/ui/tag";

export default function Example() {
  return <Tag>Marketing</Tag>;
}

View code

Tag · With icon

Installation

pnpm dlx velaui add @velaui/tag
pnpm dlx velaui add @velaui/tag

Code

tag-with-icon.tsx
import { Stars } from "@/components/icons/stars";
import { Tag, TagIcon } from "@/components/ui/tag";

export default function Example() {
  return (
    <Tag>
      <TagIcon>
        <Stars />
      </TagIcon>
      Featured
    </Tag>
  );
}

View code

Tag · With prefix

Installation

pnpm dlx velaui add @velaui/tag
pnpm dlx velaui add @velaui/tag

Code

tag-with-prefix.tsx
import { Tag, TagPrefix } from "@/components/ui/tag";

export default function Example() {
  return (
    <Tag size="md">
      <TagPrefix>24</TagPrefix>
      Open tasks
    </Tag>
  );
}

View code

Tag · Selected

Installation

pnpm dlx velaui add @velaui/tag
pnpm dlx velaui add @velaui/tag

Code

tag-selected.tsx
import { Tag } from "@/components/ui/tag";

export default function Example() {
  return <Tag selected>Selected</Tag>;
}

View code

Tag · With avatar

Installation

pnpm dlx velaui add @velaui/tag
pnpm dlx velaui add @velaui/tag

Code

tag-with-avatar.tsx
import { Tag, TagClose } from "@/components/ui/tag";
import {
  Avatar,
  AvatarImage,
  AvatarFallback,
} from "@/components/ui/avatar";

export default function Example() {
  return (
    <Tag>
      <Avatar size="2xs">
        <AvatarImage src="https://i.pravatar.cc/40?img=12" alt="Jane Cooper" />
        <AvatarFallback>JC</AvatarFallback>
      </Avatar>
      Jane Cooper
      <TagClose />
    </Tag>
  );
}

View code

Tag · Disabled

Installation

pnpm dlx velaui add @velaui/tag
pnpm dlx velaui add @velaui/tag

Code

tag-disabled.tsx
import { Tag } from "@/components/ui/tag";

export default function Example() {
  return <Tag disabled>Archived</Tag>;
}

Usage

import { Tag } from "@/components/ui/tag";

export default function Example() {
  return <Tag>Marketing</Tag>;
}

Props

Tag

PropTypeDefault
asChildbooleanfalse
disabledbooleanfalse
selectedbooleanfalse
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

PropTypeDefault
disabledbooleanfalse
selectedbooleanfalse
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 };