Actions
Chips
Assist, suggestion, filter and input chips.
Ada LovelaceAlan TuringGrace Hopper
Chips are compact elements for actions, filters, suggestions and entities. Assist chips trigger a related action, suggestion chips offer generated replies, filter chips toggle a filter on or off, and input chips represent something the user entered (with a remove button).
Installation
npx @grenishrai/expressive add chipThis copies chip.tsx into your components folder, along with what it needs from Expressive (utils, ripple, styles), and installs @base-ui/react.
Usage
import { AssistChip, FilterChip, InputChip, SuggestionChip } from "@/components/expressive/chip";
<AssistChip icon={<CalendarIcon />}>Add to calendar</AssistChip>
<SuggestionChip>Sounds good</SuggestionChip>
<FilterChip defaultPressed>Vegetarian</FilterChip>
<InputChip onRemove={() => remove(id)}>Ada Lovelace</InputChip>Props
AssistChip / SuggestionChip
| Prop | Type | Default | Description |
|---|---|---|---|
| icon | ReactNode | — | Leading icon. |
| appearance | "outlined" | "elevated" | "outlined" | Use elevated on images or busy backgrounds. |
| motion | "expressive" | "standard" | "fluid" | "none" | theme default | Motion scheme for this component's transitions. none turns animation off. |
FilterChip
| Prop | Type | Default | Description |
|---|---|---|---|
| pressed / defaultPressed | boolean | — | Whether the filter is applied. |
| onPressedChange | (pressed: boolean) => void | — | Called when toggled. |
| icon | ReactNode | — | Optional leading icon, replaced by the check mark when selected. |
InputChip
| Prop | Type | Default | Description |
|---|---|---|---|
| onRemove | () => void | — | Shows a remove button. Also fires on Backspace/Delete when it's focused. |
| removeLabel | string | "Remove {label}" | Accessible name of the remove button. |
| icon | ReactNode | — | Avatar or icon. |
| selected | boolean | — | Selected appearance. |
Accessibility
- Assist, suggestion and filter chips are native buttons; filter chips expose
aria-pressed. - The input chip's remove button has its own accessible name, e.g. “Remove Ada Lovelace”.
Source
This is the file the CLI writes into your project, shown with the default import paths. It's yours to change.
"use client";
import * as React from "react";
import { Button as BaseButton } from "@base-ui/react/button";
import { Toggle as BaseToggle } from "@base-ui/react/toggle";
import { cn } from "@/lib/expressive/utils";
import type { MotionProp } from "@/lib/expressive/use-motion";
import { useComposedRef, useFluidPress } from "@/lib/expressive/use-fluid";
import { Ripple } from "@/components/expressive/ripple";
/*
* Chips: assist (an action), suggestion (a generated option), filter (a
* toggle with a check that slides in) and input (a removable entity).
*/
const base = [
"state-layer focus-ring relative inline-flex h-8 shrink-0 cursor-pointer select-none items-center gap-2 whitespace-nowrap",
"rounded-shape-small px-4 text-label-large text-on-surface-variant",
"transition-[border-radius,background-color,color,box-shadow,padding] spring-spatial-fast active:rounded-shape-medium",
"disabled:cursor-default disabled:text-on-surface/(--ex-disabled-content) data-disabled:cursor-default",
"[&_svg]:pointer-events-none [&_svg]:size-4.5 [&_svg]:shrink-0",
];
const surface = {
outlined: "border border-outline-variant disabled:border-on-surface/(--ex-disabled-container)",
elevated: "bg-surface-container-low shadow-elevation-1 hover:shadow-elevation-2 disabled:bg-on-surface/(--ex-disabled-container) disabled:shadow-none",
};
interface ChipOwnProps {
/** `elevated` chips sit on images or busy backgrounds. */
appearance?: keyof typeof surface;
icon?: React.ReactNode;
motion?: MotionProp;
className?: string;
ref?: React.Ref<HTMLElement>;
}
export type AssistChipProps = ChipOwnProps & Omit<BaseButton.Props, "className">;
export function AssistChip({ className, appearance = "outlined", icon, motion, disabled, children, ref, ...props }: AssistChipProps) {
const local = React.useRef<HTMLElement>(null);
useFluidPress(local, motion, !disabled);
return (
<BaseButton
ref={useComposedRef(local, ref)}
data-slot="assist-chip"
data-motion={motion}
disabled={disabled}
className={cn(base, surface[appearance], "text-on-surface [&_svg]:text-primary", icon && "pl-2", className)}
{...props}
>
{icon}
{children}
<Ripple motion={motion} disabled={disabled} />
</BaseButton>
);
}
export type SuggestionChipProps = AssistChipProps;
export function SuggestionChip({ className, appearance = "outlined", icon, motion, disabled, children, ref, ...props }: SuggestionChipProps) {
const local = React.useRef<HTMLElement>(null);
useFluidPress(local, motion, !disabled);
return (
<BaseButton
ref={useComposedRef(local, ref)}
data-slot="suggestion-chip"
data-motion={motion}
disabled={disabled}
className={cn(base, surface[appearance], icon && "pl-2", className)}
{...props}
>
{icon}
{children}
<Ripple motion={motion} disabled={disabled} />
</BaseButton>
);
}
export type FilterChipProps = ChipOwnProps & Omit<BaseToggle.Props, "className">;
/** Use `pressed` / `onPressedChange` (or `defaultPressed`) for the selected state. */
export function FilterChip({ className, appearance = "outlined", icon, motion, disabled, children, ref, ...props }: FilterChipProps) {
const local = React.useRef<HTMLElement>(null);
useFluidPress(local, motion, !disabled);
return (
<BaseToggle
ref={useComposedRef(local, ref)}
data-slot="filter-chip"
data-motion={motion}
disabled={disabled}
className={cn(
base,
surface[appearance],
// Symmetric 16px padding until a leading icon or the check mark takes the space.
icon ? "group pl-2" : "group data-pressed:pl-2",
"data-pressed:border-transparent data-pressed:bg-secondary-container data-pressed:text-on-secondary-container",
className,
)}
{...props}
>
{/* Leading slot: the check grows in on selection (or replaces `icon`). */}
<span
className={cn(
"grid transition-[grid-template-columns,margin] spring-spatial-fast",
icon ? "grid-cols-[1fr]" : "-mr-2 grid-cols-[0fr] group-data-pressed:mr-0 group-data-pressed:grid-cols-[1fr]",
)}
>
<span className="flex overflow-hidden">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" className={cn(icon && "hidden group-data-pressed:block")}>
<path d="M9.55 17.65 4.2 12.3l1.43-1.4 3.92 3.92 8.82-8.82 1.43 1.4Z" />
</svg>
{icon && <span className="contents group-data-pressed:hidden">{icon}</span>}
</span>
</span>
{children}
<Ripple motion={motion} disabled={disabled} />
</BaseToggle>
);
}
export interface InputChipProps extends Omit<React.ComponentProps<"span">, "children"> {
children: React.ReactNode;
icon?: React.ReactNode;
/** Called by the trailing remove button (also on Backspace/Delete). */
onRemove?: () => void;
/** Accessible name for the remove button. */
removeLabel?: string;
selected?: boolean;
disabled?: boolean;
}
export function InputChip({ className, children, icon, onRemove, removeLabel, selected, disabled, ...props }: InputChipProps) {
const label = removeLabel ?? (typeof children === "string" ? `Remove ${children}` : "Remove");
return (
<span
data-slot="input-chip"
data-selected={selected ? "" : undefined}
aria-disabled={disabled || undefined}
className={cn(
"inline-flex h-8 shrink-0 select-none items-center gap-2 whitespace-nowrap rounded-shape-small border border-outline-variant px-3 text-label-large text-on-surface-variant",
"data-selected:border-transparent data-selected:bg-secondary-container data-selected:text-on-secondary-container",
"aria-disabled:opacity-(--ex-disabled-content)",
icon && "pl-1",
onRemove && "pr-1",
"[&_svg]:size-4.5 [&_svg]:shrink-0",
className,
)}
{...props}
>
{icon && <span className="flex size-6 items-center justify-center overflow-hidden rounded-shape-full">{icon}</span>}
{children}
{onRemove && (
<button
type="button"
aria-label={label}
disabled={disabled}
onClick={onRemove}
onKeyDown={(event) => {
if (event.key === "Backspace" || event.key === "Delete") onRemove();
}}
className="state-layer focus-ring flex size-6 cursor-pointer items-center justify-center rounded-shape-full disabled:cursor-default"
>
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">
<path d="M6.4 19 5 17.6l5.6-5.6L5 6.4 6.4 5l5.6 5.6L17.6 5 19 6.4 13.4 12l5.6 5.6-1.4 1.4-5.6-5.6Z" />
</svg>
</button>
)}
</span>
);
}