Skip to content
Component

Chip

A pill for a tag or filter: a link when it has an href, a plain label when it does not.

  • #arc
  • #component
  • #chip
  • #tag
  • #filter
  • #pill
  • #link

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/chip.json

chip.module.csscss
.list { display: flex; flex-wrap: wrap; margin: 0; padding: 0; gap: var(--space-2); list-style: none; }
.chip { display: inline-flex; min-height: var(--control-height-sm); align-items: center; gap: var(--space-1); border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 0 14px; background: var(--surface); color: var(--text-secondary); font-size: var(--text-sm); font-weight: 500; line-height: 20px; text-decoration: none; white-space: nowrap; }
.sm { min-height: 26px; padding: 0 10px; font-size: var(--text-xs); }
/* The number is context, not the label, so it stays a step quieter than the word it follows. */
.count { color: var(--text-muted); font-weight: 400; font-variant-numeric: tabular-nums; }
.link { -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) {
  .link:hover { border-color: var(--border-strong); color: var(--foreground); }
}
.link:active { background: var(--surface-muted); border-color: transparent; color: var(--foreground); transform: scale(.97); }
/* The current filter is told apart by a filled face and a stronger edge, not by color alone. */
.link[aria-current="true"] { border-color: transparent; background: var(--accent-subtle); color: var(--foreground); }
.link[aria-current="true"] .count { color: var(--accent); }
@media (hover: hover) and (pointer: fine) {
  .link[aria-current="true"]:hover { border-color: transparent; background: var(--accent-subtle); }
}
@media (prefers-reduced-motion: reduce) { .link { transition: none; } .link:active { transform: none; } }
chip.tsxtsx
"use client";

import type { HTMLAttributes, ReactNode } from "react";
import { ArcLink } from "../lib/arc-provider";
import { playUiSound } from "../sound/play";
import styles from "./chip.module.css";

export type ChipSize = "sm" | "md";

export interface ChipProps {
  /** Makes the chip a link. Without it the chip is a plain label. */
  href?: string;
  /** Marks the filter the reader is on. Only meaningful with `href`. */
  current?: boolean;
  size?: ChipSize;
  /** A quiet number after the label, such as how many items a tag holds. */
  count?: number | string;
  /** The language of the label, when it differs from the page. */
  lang?: string;
  className?: string;
  children: ReactNode;
}

/**
 * A pill for a tag or a filter. With `href` it goes somewhere; without one it is a label and gets no hover or press,
 * because nothing happens when you touch it. For chips that are *chosen* rather than followed — a filter that changes
 * the list in place — use ChipGroup, which keeps selection and arrow-key movement.
 */
export function Chip({ href, current = false, size = "md", count, lang, className, children }: ChipProps) {
  const classes = [styles.chip, styles[size], className].filter(Boolean).join(" ");
  const content = (
    <>
      {children}
      {count === undefined ? null : <span className={styles.count}>{count}</span>}
    </>
  );
  if (!href) {
    return <span className={classes} lang={lang}>{content}</span>;
  }
  return (
    <ArcLink
      href={href}
      lang={lang}
      className={[classes, styles.link].join(" ")}
      aria-current={current ? "true" : undefined}
      onClick={() => playUiSound("tick")}
    >
      {content}
    </ArcLink>
  );
}

export interface ChipListProps extends HTMLAttributes<HTMLUListElement> {
  /** The accessible name of the row, e.g. "태그". */
  label?: string;
  children: ReactNode;
}

/** A wrapping row of chips as a real list, so a screen reader announces how many there are. Wrap each Chip in an `<li>`. */
export function ChipList({ className, label, children, ...props }: ChipListProps) {
  return (
    <ul {...props} aria-label={label} className={[styles.list, className].filter(Boolean).join(" ")}>
      {children}
    </ul>
  );
}

Chip.displayName = "Chip";
ChipList.displayName = "ChipList";

export default Chip;