Lunia DS

Base components

Tag

A label for a filter, a category or a chosen option that can carry a count, a remove button or a checkbox, and that fades out and closes its gap when it is removed.

Loading preview

A filter row with tags Design, Product and Marketing that can be removed, tags with a dot, a flag and an avatar, tags with counts, and a group of tags with checkboxes. Replay removes the middle tag so the row closes the gap, then adds it back.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add tag

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { Tag } from './tag';

const FILTERS = ['Design', 'Product', 'Marketing'];
const CHANNELS = ['Email', 'SMS', 'Push'];

function Flag() {
  return (
    <svg aria-hidden="true" viewBox="0 0 16 16">
      <path d="M0 8a8 8 0 0 1 16 0Z" fill="var(--lunia-red-500)" />
      <path d="M0 8a8 8 0 0 0 16 0Z" fill="var(--lunia-base-white)" />
      <circle cx="8" cy="8" r="7.75" fill="none" stroke="var(--lunia-border-secondary)" strokeWidth="0.5" />
    </svg>
  );
}

function Person() {
  return (
    <svg aria-hidden="true" viewBox="0 0 16 16" className="bg-tertiary text-fg-quaternary">
      <circle cx="8" cy="6.5" r="2.5" fill="currentColor" />
      <path d="M3 14.5a5 5 0 0 1 10 0Z" fill="currentColor" />
    </svg>
  );
}

export default function TagDemo({ replay = 0 }: { replay?: number }) {
  const [filters, setFilters] = React.useState(FILTERS);
  const [channels, setChannels] = React.useState([true, false, true]);
  const row = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    if (replay === 0) return;
    // The middle tag leaves and the row closes the gap, then it comes back.
    const button = row.current?.querySelector<HTMLButtonElement>('[data-tag="Product"] button');
    button?.click();
    const timer = window.setTimeout(() => setFilters(FILTERS), button ? 900 : 200);
    return () => window.clearTimeout(timer);
  }, [replay]);

  return (
    <div className="flex flex-col items-start gap-5">
      <div ref={row} className="flex flex-wrap items-center gap-2">
        {FILTERS.filter((name) => filters.includes(name)).map((name) => (
          <Tag
            key={name}
            data-tag={name}
            size="md"
            onRemove={() => setFilters((list) => list.filter((n) => n !== name))}
          >
            {name}
          </Tag>
        ))}
      </div>
      <div className="flex flex-wrap items-center gap-2">
        <Tag size="md" dot="fg-success-secondary">
          Active
        </Tag>
        <Tag size="md" leading={<Flag />}>
          Indonesia
        </Tag>
        <Tag size="md" leading={<Person />}>
          Olivia
        </Tag>
      </div>
      <div className="flex flex-wrap items-center gap-2">
        <Tag size="lg" count={12}>
          Design
        </Tag>
        <Tag size="lg" count={8}>
          Engineering
        </Tag>
      </div>
      <div className="flex flex-wrap items-center gap-2">
        {CHANNELS.map((name, i) => (
          <Tag
            key={name}
            checked={channels[i]}
            onCheckedChange={(value) => setChannels((list) => list.map((v, j) => (j === i ? value : v)))}
          >
            {name}
          </Tag>
        ))}
      </div>
    </div>
  );
}

API

Parts: root, checkbox (optional, before the content), leading content (dot, country flag or avatar), label, count, remove button (X close).

PropTypeDefaultDescription
size"sm" | "md" | "lg""sm"24, 24 or 28px tall (Figma); md has larger text than sm.
dotstringundefinedShows a dot before the label in this color token, for example fg-success-secondary.
countnumberundefinedShows a count after the label. Not shown when the remove button is shown.
checkedbooleanundefinedShows the checkbox with this state, for a tag that can be picked. The checkbox shows only when checked or defaultChecked is set.
defaultCheckedbooleanundefinedCheckbox state when uncontrolled.
disabledbooleanfalseFades the checkbox and the remove button to 50% and turns them off.
removeLabelstring"Remove"Verb for the remove button name. The button is named removeLabel plus the tag text.
EventDescription
removeThe remove button was pressed, or Backspace or Delete was pressed on it. The remove button renders only when this event has a listener.
checkedChangeThe checkbox changed. Payload: the new checked state.
SlotDescription
defaultLabel text.
leadingFlag or avatar before the label (Figma Icon Country or Avatar).

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
TAG1hover the remove buttonremove buttonbackground-color, icon colortransparent, fg-tertiary → bg-primary_hover, fg-tertiary_hoverduration-sm (200)ease-outSwaps at once
TAG2keyboard focus on the remove button or the checkboxremove button, checkboxbox-shadownone → compact focus ring (1px gap, 3px focus-ring)duration-2xs (120)ease-outShows at once
TAG3the tag is added after first rendertagopacity, scale0, scale-press → 1, 1duration-sm (200)ease-outShows at once
TAG4the tag is removedtagopacity, scale1, 1 → 0, scale-pressduration-xs (160)ease-exitHides at once
TAG5the tag is removedtag box (so the next tags close the gap)width, margin-inline-endtag width, gap → 0, 0duration-md (260)ease-settledelay 60msResizes at once
TAG6checked -> true / falsecheckboxbackground-color, border-colorbg-primary, border-control → bg-brand-solid, bg-brand-solidduration-sm (200)ease-outSwaps at once
TAG7checked -> truecheck markstroke-dashoffsetpath length → 0duration-md (260)ease-outdelay 60msShows at once

Accessibility

  • The tag is a plain element; only its checkbox and remove button are focusable.
  • The remove button is a native button named removeLabel plus the tag text, for example "Remove Design"; Backspace and Delete on it also remove the tag.
  • After a remove, focus moves to the next tag's remove button, or the previous one, or the input that owns the list; the parent decides with the remove event.
  • The checkbox is a real checkbox (role checkbox, aria-checked) named by the tag text.
  • The remove button is 14 to 20px and the checkbox 14 to 18px: each gets a 24 x 24px tap area (WCAG 2.5.8).
  • The dot, flag and avatar are aria-hidden; a count is read with its meaning, for example "Design, 12 items", through aria-label on the count.
  • Reduced motion: tags appear and leave at once and the gap closes at once.