Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Chip

A compact element for input, selection or filtering.

A compact chip. iOS renders a tinted rounded pill (secondary container); Android adds a ripple on the interactive surface. Three simplified variants cover the MD3 chip set: a static default assist label, a selectable filter chip and a dismissible input chip.

Installation

npx touchcn add chip

Usage

<tcn-chip>Design</tcn-chip>

<tcn-chip variant="selectable" [(selected)]="on">Available</tcn-chip>

<tcn-chip variant="dismissible" (remove)="remove()">Coffee</tcn-chip>
import { TcnChip } from '@/components/ui/chip';

<TcnChip>Design</TcnChip>

<TcnChip variant="selectable" selected={on} onSelectedChange={setOn}>
  Available
</TcnChip>

<TcnChip variant="dismissible" onRemove={remove}>
  Coffee
</TcnChip>
<script setup lang="ts">
import { TcnChip } from '@/components/ui/chip';
</script>

<template>
  <TcnChip>Design</TcnChip>

  <TcnChip variant="selectable" v-model:selected="on">Available</TcnChip>

  <TcnChip variant="dismissible" @remove="remove">Coffee</TcnChip>
</template>

The selectable chip reveals a leading checkmark when on; the dismissible chip fires its removal callback from the trailing × button.

Props

Prop Type Default Description
variant 'default' | 'selectable' | 'dismissible' 'default' Chip behaviour.
selected boolean false Selected state (selectable).
onSelectedChange (selected: boolean) => void Toggle callback (React).
onRemove / remove () => void Fires on dismiss (dismissible).
disabled boolean false Disable interaction.
Source · Angular
export * from './tcn-chip';
import { NgTemplateOutlet } from '@angular/common';
import { booleanAttribute, Component, input, model, output } from '@angular/core';
import { TcnRippleDirective } from '@touchcn/angular/ripple';

export type TcnChipVariant = 'default' | 'selectable' | 'dismissible';

/**
 * Compact chip. Three simplified variants cover the MD3 chip set: `default` (a
 * static assist label), `selectable` (a filter chip that toggles `selected`,
 * showing a leading checkmark when on) and `dismissible` (an input chip with a
 * trailing × that emits `remove`). iOS renders a tinted rounded pill
 * (secondary-container); MD adds a ripple on the interactive surface. All
 * styling lives in `theme.css`; the ripple is MD-only by cascade.
 */
@Component({
  selector: 'tcn-chip',
  imports: [NgTemplateOutlet, TcnRippleDirective],
  // The label lives in a single #label template projected into the active branch.
  // Angular sends light-DOM content to only one <ng-content>, so declaring it once
  // (and outletting it per variant) is what keeps every variant's label rendering.
  template: `
    <ng-template #label><span class="tcn-chip-label"><ng-content /></span></ng-template>
    @switch (variant()) {
      @case ('selectable') {
        <button
          type="button"
          tcnRipple
          class="tcn-chip"
          [attr.aria-pressed]="selected()"
          [attr.data-selected]="selected()"
          [disabled]="disabled()"
          (click)="toggle()"
        >
          @if (selected()) {
            <span class="tcn-chip-check" aria-hidden="true">
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
                <path d="M5 12l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
              </svg>
            </span>
          }
          <ng-container [ngTemplateOutlet]="label" />
        </button>
      }
      @case ('dismissible') {
        <div class="tcn-chip">
          <ng-container [ngTemplateOutlet]="label" />
          <button
            type="button"
            tcnRipple
            class="tcn-chip-remove"
            aria-label="Remove"
            [disabled]="disabled()"
            (click)="remove.emit()"
          >
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none">
              <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
            </svg>
          </button>
        </div>
      }
      @default {
        <div class="tcn-chip"><ng-container [ngTemplateOutlet]="label" /></div>
      }
    }
  `,
  host: { class: 'inline-flex' },
})
export class TcnChip {
  readonly variant = input<TcnChipVariant>('default');
  readonly selected = model(false);
  readonly disabled = input(false, { transform: booleanAttribute });
  readonly remove = output<void>();

  protected toggle(): void {
    if (!this.disabled()) {
      this.selected.update((value) => !value);
    }
  }
}
Source · React
export * from './tcn-chip';
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
import { cn } from '@touchcn/core';
import { useRipple } from '@touchcn/react';

export type TcnChipVariant = 'default' | 'selectable' | 'dismissible';

export interface TcnChipProps {
  variant?: TcnChipVariant;
  selected?: boolean;
  onSelectedChange?: (selected: boolean) => void;
  onRemove?: () => void;
  disabled?: boolean;
  className?: string;
  children?: ReactNode;
}

const CheckIcon = () => (
  <span className="tcn-chip-check" aria-hidden="true">
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
      <path d="M5 12l4 4L19 7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  </span>
);

/**
 * Compact chip. Three simplified variants cover the MD3 chip set: `default` (a
 * static assist label), `selectable` (a filter chip that toggles `selected`,
 * showing a leading checkmark when on) and `dismissible` (an input chip with a
 * trailing × that fires `onRemove`). iOS renders a tinted rounded pill
 * (secondary-container); MD adds a ripple on the interactive surface via the
 * engine `useRipple` hook. Emits the same `tcn-chip*` classes and `data-selected`
 * attribute as the Angular component; the ripple is MD-only by cascade.
 */
export function TcnChip({
  variant = 'default',
  selected = false,
  onSelectedChange,
  onRemove,
  disabled,
  className,
  children,
}: TcnChipProps) {
  const { hostRef, hostClassName, rippleHandlers } = useRipple<HTMLButtonElement>();

  if (variant === 'selectable') {
    const handleClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
      if (!disabled) {
        onSelectedChange?.(!selected);
      }
      rippleHandlers.onClick(event);
    };
    return (
      <button
        ref={hostRef}
        type="button"
        className={cn('tcn-chip', hostClassName, className)}
        aria-pressed={selected}
        data-selected={selected}
        disabled={disabled}
        onPointerDown={rippleHandlers.onPointerDown}
        onPointerCancel={rippleHandlers.onPointerCancel}
        onClick={handleClick}
      >
        {selected && <CheckIcon />}
        <span className="tcn-chip-label">{children}</span>
      </button>
    );
  }

  if (variant === 'dismissible') {
    const handleClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
      onRemove?.();
      rippleHandlers.onClick(event);
    };
    return (
      <span className={cn('tcn-chip', className)}>
        <span className="tcn-chip-label">{children}</span>
        <button
          ref={hostRef}
          type="button"
          className={cn('tcn-chip-remove', hostClassName)}
          aria-label="Remove"
          disabled={disabled}
          onPointerDown={rippleHandlers.onPointerDown}
          onPointerCancel={rippleHandlers.onPointerCancel}
          onClick={handleClick}
        >
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none">
            <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
          </svg>
        </button>
      </span>
    );
  }

  return (
    <span className={cn('tcn-chip', className)}>
      <span className="tcn-chip-label">{children}</span>
    </span>
  );
}
Source · Vue
<script lang="ts">
export type TcnChipVariant = 'default' | 'selectable' | 'dismissible';
</script>

<script setup lang="ts">
import { cn } from '@touchcn/core';
import { useRipple } from '@touchcn/vue';

/**
 * Compact chip. Three simplified variants cover the MD3 chip set: `default` (a
 * static assist label), `selectable` (a filter chip that toggles `selected`,
 * showing a leading checkmark when on) and `dismissible` (an input chip with a
 * trailing × that emits `remove`). iOS renders a tinted rounded pill
 * (secondary-container); MD adds a ripple on the interactive surface via the
 * engine `useRipple` hook. Emits the same `tcn-chip*` classes and
 * `data-selected` attribute as the Angular / React counterparts; the ripple is
 * MD-only by cascade. `selected` is two-way bound (`v-model:selected`).
 */
const props = withDefaults(
  defineProps<{ variant?: TcnChipVariant; disabled?: boolean }>(),
  { variant: 'default', disabled: false },
);

const selected = defineModel<boolean>('selected', { default: false });
const emit = defineEmits<{ remove: [] }>();

const { setHost, hostClassName, onPointerdown, onPointercancel, onClick } = useRipple();

const onSelectableClick = (event: MouseEvent): void => {
  if (!props.disabled) {
    selected.value = !selected.value;
  }
  onClick(event);
};

const onDismissClick = (event: MouseEvent): void => {
  emit('remove');
  onClick(event);
};
</script>

<template>
  <button
    v-if="variant === 'selectable'"
    :ref="setHost"
    type="button"
    :class="cn('tcn-chip', hostClassName)"
    :aria-pressed="selected"
    :data-selected="selected"
    :disabled="disabled"
    @pointerdown="onPointerdown"
    @pointercancel="onPointercancel"
    @click="onSelectableClick"
  >
    <span v-if="selected" class="tcn-chip-check" aria-hidden="true">
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
        <path
          d="M5 12l4 4L19 7"
          stroke="currentColor"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </span>
    <span class="tcn-chip-label"><slot /></span>
  </button>

  <span v-else-if="variant === 'dismissible'" class="tcn-chip">
    <span class="tcn-chip-label"><slot /></span>
    <button
      :ref="setHost"
      type="button"
      :class="cn('tcn-chip-remove', hostClassName)"
      aria-label="Remove"
      :disabled="disabled"
      @pointerdown="onPointerdown"
      @pointercancel="onPointercancel"
      @click="onDismissClick"
    >
      <svg width="14" height="14" viewBox="0 0 24 24" fill="none">
        <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
      </svg>
    </button>
  </span>

  <span v-else class="tcn-chip">
    <span class="tcn-chip-label"><slot /></span>
  </span>
</template>
export { default as TcnChip } from './TcnChip.vue';
export type { TcnChipVariant } from './TcnChip.vue';

Last updated on July 24, 2026

Was this page helpful?