Tag
A label chip for a topic, rendered on the server. Passing onRemove adds an x button, the only part that runs in the browser.
split - size not measured yet
Tag
split - size not measured yetA label chip for a topic, rendered on the server. Passing onRemove adds an x button, the only part that runs in the browser.
Design systems
Try it
tone
size
The code
tag.tsx
import { Tag } from 'topclassui/tag'
<Tag>Design systems</Tag>A plain Tag stays on the server; onRemove is the switch that adds the x, and taking the tag out of the list is yours.
Every state
2 statesPlainRemovable
Import from the package
topclassui/tag
import { Tag } from 'topclassui/tag'Install once:npm i topclassui@beta zod
Examples
- For a topic, a category or a keyword, usually several in a wrapping row. For a status such as Paid or Overdue, use a Badge.
- Tag has no ARIA role, so the text carries the meaning; colour alone never does.
tonedefaults to neutral, so a row of tags stays quiet. - The x only calls
onRemove. A Server Component can pass it only as a Server Action; to keep the list in state, put it in a client component. - A Tag stays on one line: plain text ends in an ellipsis, an element given as the label is clipped at the edge, and the x always stays whole. In a row that does not wrap, add
className="shrink"to let it shrink. - For a linked tag, put
tagVariants()on the link itself and the label in a span of its own; never put a link inside a Tag.
topics
Design systemsAccessibilityReactTailwindRight to leftServer Components
topics.tsx
import { Tag } from 'topclassui/tag'
const TOPICS = ['Design systems', 'Accessibility', 'React', 'Tailwind', 'Right to left', 'Server Components']
/** The docs sample: a wrapping row of topics, each at the default tone. */
export default function Demo() {
return (
<div className="flex flex-wrap gap-2">
{TOPICS.map((topic) => <Tag key={topic} tone="neutral">{topic}</Tag>)}
</div>
)
}
tones
PrimaryNeutralSuccessWarningDangerInfo
tones.tsx
import { Tag } from 'topclassui/tag'
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-2">
<Tag tone="primary">Primary</Tag>
<Tag tone="neutral">Neutral</Tag>
<Tag tone="success">Success</Tag>
<Tag tone="warning">Warning</Tag>
<Tag tone="danger">Danger</Tag>
<Tag tone="info">Info</Tag>
</div>
)
}
sizes
Extra smallSmallMediumLargeExtra large
sizes.tsx
import { Tag } from 'topclassui/tag'
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
<Tag size="xs">Extra small</Tag>
<Tag size="sm">Small</Tag>
<Tag size="md">Medium</Tag>
<Tag size="lg">Large</Tag>
<Tag size="xl">Extra large</Tag>
</div>
)
}
with-icon
VerifiedDraft
with-icon.tsx
import { checkCircle } from 'topclassui/icons/check-circle'
import { Svg } from 'topclassui/svg'
import { Tag } from 'topclassui/tag'
/** The icon is decoration, in its own flex slot; the word carries the meaning. */
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-2">
<Tag tone="success">
<Svg icon={checkCircle} />
Verified
</Tag>
<Tag>Draft</Tag>
</div>
)
}
removable
- Design systems
- Accessibility
- React
- Tailwind
- Right to left
- Server Components
removable.tsx
'use client'
import { useEffect, useRef, useState } from 'react'
import { Button } from 'topclassui/button'
import { TagRemovable } from 'topclassui/tag'
const TOPICS = ['Design systems', 'Accessibility', 'React', 'Tailwind', 'Right to left', 'Server Components']
/** TagRemovable takes a handler, so a client file renders it. The caller owns the list and takes the topic out; the x hands the focus to the
* next x, or the one before, and when the list empties the caller moves it to the button that brings it back. */
export default function Demo() {
const [topics, setTopics] = useState(TOPICS)
const restore = useRef<HTMLButtonElement>(null)
// Runs after the x's own move, so the button that brings the list back takes the focus.
useEffect(() => {
if (topics.length === 0) restore.current?.focus()
}, [topics])
return (
<div className="flex flex-col items-start gap-3">
<ul aria-label="Topics" className="flex flex-wrap gap-2">
{topics.map((topic) => (
<li key={topic} className="flex max-w-full">
<TagRemovable onRemove={() => { setTopics(topics.filter((other) => other !== topic)) }}>
{topic}
</TagRemovable>
</li>
))}
</ul>
{topics.length === 0 ? (
<Button ref={restore} variant="outline" size="sm" onClick={() => { setTopics(TOPICS) }}>Restore the topics</Button>
) : null}
</div>
)
}
removable-sizes
Extra smallSmallMediumLargeExtra large
removable-sizes.tsx
'use client'
import { useRef, useState } from 'react'
import { Button } from 'topclassui/button'
import { TagRemovable } from 'topclassui/tag'
const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'] as const
const NAMES = { xs: 'Extra small', sm: 'Small', md: 'Medium', lg: 'Large', xl: 'Extra large' } as const
/** Every step of the scale with its x. A removed tag sends the focus to the button that brings it back. */
export default function Demo() {
const [shown, setShown] = useState<readonly string[]>(SIZES)
const back = useRef<HTMLButtonElement>(null)
return (
<div className="flex flex-col items-start gap-3">
<div className="flex flex-wrap items-center gap-3">
{SIZES.filter((size) => shown.includes(size)).map((size) => (
<TagRemovable key={size} size={size} tone="info" onRemove={() => { setShown(shown.filter((other) => other !== size)); back.current?.focus() }}>
{NAMES[size]}
</TagRemovable>
))}
</div>
<Button ref={back} variant="outline" size="sm" onClick={() => { setShown(SIZES) }}>Show every size</Button>
</div>
)
}
link
link.tsx
import { cn } from 'topclassui/cn'
import { FOCUS_RING, LABEL, TOUCH_TARGET } from 'topclassui/classes'
import { tagVariants } from 'topclassui/tag'
const LONG = 'Infrastructure, platform reliability and developer experience working group'
const chip = cn(tagVariants({ tone: 'info' }), FOCUS_RING, TOUCH_TARGET)
/** A linked tag: the link is the chip, so its focus ring is never clipped. */
export default function Demo() {
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<a href="#design-systems" className={chip}>Design systems</a>
<a href="#accessibility" className={chip}>Accessibility</a>
</div>
<div className="w-50 rounded-box border border-line p-2" data-narrow>
<a href="#working-group" className={chip}><span className={LABEL}>{LONG}</span></a>
</div>
<div className="w-50 rounded-box border border-line p-2" data-narrow>
<a href="#platform" className={chip}>{LONG}</a>
</div>
</div>
)
}
long-label
Infrastructure, platform reliability and developer experience working group
Infrastructure, platform reliability and developer experience working group
Infrastructure, platform reliability and developer experience working group
Infrastructure, platform reliability and developer experience working groupInfrastructure, platform reliability and developer experience working group
long-label.tsx
import { checkCircle } from 'topclassui/icons/check-circle'
import { Svg } from 'topclassui/svg'
import { Tag } from 'topclassui/tag'
const LONG = 'Infrastructure, platform reliability and developer experience working group'
export default function Demo() {
return (
<div className="flex flex-col gap-3">
<div className="w-50 rounded-box border border-line p-2" data-narrow>
<Tag>{LONG}</Tag>
</div>
<div className="w-50 rounded-box border border-line p-2" data-narrow>
<Tag tone="warning">
<Svg icon={checkCircle} />
{LONG}
</Tag>
</div>
<div className="w-50 rounded-box border border-line p-2" data-narrow>
<Tag tone="info" size="xs">{LONG}</Tag>
</div>
<div className="flex w-50 gap-2 rounded-box border border-line p-2" data-narrow>
<Tag className="shrink">{LONG}</Tag>
<Tag className="shrink" tone="success">{LONG}</Tag>
</div>
</div>
)
}
element-label
Infrastructure, platform reliability and developer experience working group
Infrastructure, platform reliability and developer experience working group
sha256_9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08element-label.tsx
import { checkCircle } from 'topclassui/icons/check-circle'
import { Svg } from 'topclassui/svg'
import { Tag } from 'topclassui/tag'
const LONG = 'Infrastructure, platform reliability and developer experience working group'
const DIGEST = 'sha256_9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'
/** An element as the label is clipped to the chip on one line; only bare text gets the ellipsis. */
export default function Demo() {
return (
<div className="flex flex-col gap-3">
<div className="w-50 rounded-box border border-line p-2" data-narrow>
<Tag><strong>{LONG}</strong></Tag>
</div>
<div className="w-50 rounded-box border border-line p-2" data-narrow>
<Tag tone="warning">
<Svg icon={checkCircle} />
<em>{LONG}</em>
</Tag>
</div>
<div className="w-50 rounded-box border border-line p-2" data-narrow>
<Tag tone="info" size="xs"><code>{DIGEST}</code></Tag>
</div>
</div>
)
}
custom-size
The scaleYour own row
custom-size.tsx
import { Tag } from 'topclassui/tag'
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
<Tag size="md">The scale</Tag>
<Tag size="custom" className="px-pad-x-xs py-pad-y-xs text-xs">Your own row</Tag>
</div>
)
}
In Arabic, right to left
موثّقتصميمإمكانية الوصولعاجل
rtl.tsx
import { checkCircle } from 'topclassui/icons/check-circle'
import { Svg } from 'topclassui/svg'
import { Tag } from 'topclassui/tag'
export default function Demo() {
return (
<div dir="rtl" lang="ar" className="flex flex-wrap items-center gap-2">
<Tag tone="success">
<Svg icon={checkCircle} />
موثّق
</Tag>
<Tag>تصميم</Tag>
<Tag tone="info">إمكانية الوصول</Tag>
<Tag tone="danger">عاجل</Tag>
</div>
)
}
Props
Keyboard and accessibility
- The x is named by its own word, then the label, so it reads "Remove React"; the word comes from the provider's labels ("إزالة" in Arabic).
- A removed x takes the focus with it: send it to the next x, or the one before, and to a control after the list when it empties.
- The x is 24 px, 44 under a touch pointer, with a visible focus ring.
Right to left
The padding is logical, so the x moves to the inline end and an icon to the inline start with no extra code.
Server and client
Renders on the server; the interactive part is a client component that ships only when it is used.
Files: index.tsx, client.tsx.
On the chip: split - size not measured yet.