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';