Compact selectable pill with press scale and ink overlay.
components/ui/chip/chip.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { Chip } from "@/components/ui/chip/chip";
export function Example() {
return (
<Chip />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Pressable, Text, type PressableProps, type GestureResponderEvent } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSequence,
withSpring,
withTiming,
} from "react-native-reanimated";
import { cn, createVariant, useAsyncPress, useHaptic, usePressAnimation, useReducedMotion, spring, timing, } from "@lumioui/core";
import { Icon } from "../icon";
import { Glass } from "../glass";
const chipVariants = createVariant({
base: "flex-row items-center rounded-full",
variants: {
variant: {
assist: "bg-secondary",
filter: "border border-input bg-transparent",
input: "bg-card border border-input",
suggestion: "bg-accent",
glass: "",
},
selected: {
true: "bg-primary/10 border-primary",
false: "",
},
size: {
sm: "px-2.5 py-1 gap-1",
md: "px-3 py-1.5 gap-1.5",
},
},
defaultVariants: { variant: "assist", selected: "false", size: "md" },
});
const chipTextVariants = createVariant({
base: "font-semibold",
variants: {
variant: {
assist: "text-secondary-foreground",
filter: "text-foreground",
input: "text-foreground",
suggestion: "text-accent-foreground",
glass: "text-foreground",
},
selected: {
true: "text-primary",
false: "",
},
size: {
sm: "text-xs",
md: "text-sm",
},
},
defaultVariants: { variant: "assist", selected: "false", size: "md" },
});
export interface ChipProps extends Omit<PressableProps, "children" | "onPress"> {
/** Chip visual intent — "glass" renders a liquid-glass pill. */
variant?: "assist" | "filter" | "input" | "suggestion" | "glass";
/** Density preset — "sm" for dense rails (filters, metadata tags). */
size?: "sm" | "md";
/** Controlled selected / active state. Omit for uncontrolled use with
* `defaultSelected`/`onSelectedChange`. */
selected?: boolean;
/** Initial selected state for uncontrolled use. Chips that opt into
* uncontrolled selection (via this prop or `onSelectedChange`) toggle
* themselves on press; plain action chips keep their current behavior. */
defaultSelected?: boolean;
/** Called when the user toggles selection. In controlled mode this is the
* change intent; in uncontrolled mode it also fires after the internal
* state flips. */
onSelectedChange?: (selected: boolean) => void;
/** Called on press. May return a Promise — when `asyncTracking` is on the
* chip enters its loading state until the promise resolves. */
onPress?: (e: GestureResponderEvent) => void | Promise<unknown>;
/** Delete handler — shows a trailing × button. */
onDelete?: () => void;
/** Leading icon or avatar. */
icon?: React.ReactNode;
/** Extra classes for the label. */
textClassName?: string;
/** Extra classes for the container. */
className?: string;
/** Extra classes for the container applied only while `selected` — the
* escape hatch for accent-idiom fills (e.g. a lime pill on a dark canvas)
* that the primary-tinted selected variant can't express. */
selectedClassName?: string;
/** Extra classes for the label applied only while `selected`. */
selectedTextClassName?: string;
/** Auto-track async onPress — when onPress returns a Promise, the chip
* automatically enters loading state until it resolves. Default true. */
asyncTracking?: boolean;
children?: React.ReactNode;
}
// Animated.View is cssInterop-registered via @lumioui/core/styled
/** Chip — assist/filter/input/suggestion with press animation and haptics. */
export const Chip = forwardRef<React.ElementRef<typeof Pressable>, ChipProps>(
function Chip(
{
variant = "assist",
size = "md",
selected,
defaultSelected,
onSelectedChange,
onDelete,
icon,
textClassName,
className,
selectedClassName,
selectedTextClassName,
onPress,
disabled,
asyncTracking = true,
children,
...props
},
ref
) {
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.96 });
// The × is a small nested target — deeper dip + opacity so the touch reads.
const deletePress = usePressAnimation({ pressedScale: 0.8, pressedOpacity: 0.5 });
const reduceMotion = useReducedMotion();
// Controlled when `selected` is passed; uncontrolled selection only kicks
// in when the consumer opts in (defaultSelected/onSelectedChange) so plain
// action chips never self-highlight.
const isControlled = selected !== undefined;
const isSelectable = defaultSelected !== undefined || onSelectedChange !== undefined;
const [internalSelected, setInternalSelected] = React.useState(defaultSelected ?? false);
const isSelected = isControlled ? selected : internalSelected;
const selectedKey = isSelected ? "true" : ("false" as const);
// Selection pop — a quick squash-and-bounce when `selected` flips so the
// state change lands physically instead of just repainting.
const selectPulse = useSharedValue(1);
const firstRender = React.useRef(true);
React.useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
return;
}
if (reduceMotion) return;
selectPulse.value = withSequence(
withTiming(0.93, timing.instant),
withSpring(1, spring.bouncy)
);
}, [isSelected, selectPulse, reduceMotion]);
// press.scale and the selection pulse share one transform slot.
const chipStyle = useAnimatedStyle(() => ({
transform: [{ scale: press.scale.value * selectPulse.value }],
}));
// Auto-track async onPress promises
const asyncPress = useAsyncPress(
asyncTracking ? onPress : undefined,
{
haptic: () => haptics.selection(),
}
);
const isLoading = asyncPress.loading;
const isDisabled = disabled || isLoading;
// Held-state ink overlay — a faint wash that confirms contact instantly.
const ink = useSharedValue(0);
const inkStyle = useAnimatedStyle(() => ({ opacity: ink.value * 0.12 }));
// Pressed = light crushed out of the surface, flatter face.
// Raised gloss only on opaque fills — filter chips are transparent, and
// the selected state's bg-primary/10 tint is translucent, so both stay
const handlePress = (e: GestureResponderEvent) => {
if (!isControlled && isSelectable) setInternalSelected(!isSelected);
onSelectedChange?.(!isSelected);
if (asyncTracking) {
asyncPress.handlePress(e);
} else {
haptics.selection();
onPress?.(e);
}
};
const handlePressIn = (_e: GestureResponderEvent) => {
press.onPressIn();
if (!reduceMotion) ink.value = withTiming(1, timing.instant);
};
const handlePressOut = () => {
press.onPressOut();
ink.value = withTiming(0, timing.normal);
};
const handleDelete = (e: GestureResponderEvent) => {
e.stopPropagation?.();
haptics.light();
onDelete?.();
};
return (
<Animated.View
style={chipStyle}
className={cn(
// overflow-hidden clips the square ink wash to the pill shape.
"overflow-hidden",
chipVariants({ variant, selected: selectedKey, size }),
isDisabled && "opacity-50",
className,
isSelected && selectedClassName
)}
>
{variant === "glass" ? (
<Glass
effect="clear"
sheen={0.5}
className="absolute inset-0 rounded-full"
pointerEvents="none"
/>
) : null}
{/* Ink overlay — held state. Lives on the outer pill so the wash
covers the full chip (padding included), not just the content row. */}
<Animated.View
pointerEvents="none"
className="bg-foreground"
style={[
{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 },
inkStyle,
]}
/>
<Pressable
ref={ref}
disabled={isDisabled}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
className="flex-row items-center gap-1.5"
accessibilityRole="button"
accessibilityState={{ selected: isSelected, disabled: isDisabled, busy: isLoading }}
// accessibilityState isn't forwarded to the DOM on web — mirror it
// with explicit aria-* like Switch/Toggle do.
aria-pressed={isSelected}
aria-disabled={isDisabled}
aria-busy={isLoading}
// ~32px visual height + 2×12 hitSlop ≈ 56pt effective target (≥44).
hitSlop={12}
{...props}
>
{icon}
<Text
className={cn(
chipTextVariants({ variant, selected: selectedKey, size }),
textClassName,
isSelected && selectedTextClassName
)}
>
{children}
</Text>
</Pressable>
{onDelete ? (
<Pressable
onPress={handleDelete}
onPressIn={deletePress.onPressIn}
onPressOut={deletePress.onPressOut}
// isDisabled (not disabled) — the × must also lock during the
// async onPress loading window, same as the chip body.
disabled={isDisabled}
className="ml-0.5 h-5 w-5 items-center justify-center rounded-full"
accessibilityRole="button"
accessibilityLabel="Remove chip"
hitSlop={12}
>
<Animated.View style={deletePress.animatedStyle}>
<Icon name="x" size="sm" color="mutedForeground" />
</Animated.View>
</Pressable>
) : null}
</Animated.View>
);
}
);
Chip.displayName = "Chip";
| variant? | "assist" | "filter" | "input" | "suggestion" | "glass" | Chip visual intent — "glass" renders a liquid-glass pill. | |
| size? | "sm" | "md" | Density preset — "sm" for dense rails (filters, metadata tags). | |
| selected? | boolean | Controlled selected / active state. Omit for uncontrolled use with `defaultSelected`/`onSelectedChange`. | |
| defaultSelected? | boolean | Initial selected state for uncontrolled use. Chips that opt into uncontrolled selection (via this prop or `onSelectedChange`) toggle themselves on press; plain action chips keep their current behavior. | |
| onSelectedChange? | (selected: boolean) => void | Called when the user toggles selection. In controlled mode this is the change intent; in uncontrolled mode it also fires after the internal state flips. | |
| onPress? | (e: GestureResponderEvent) => void | Promise<unknown> | Called on press. May return a Promise — when `asyncTracking` is on the chip enters its loading state until the promise resolves. | |
| onDelete? | () => void | Delete handler — shows a trailing × button. | |
| icon? | React.ReactNode | Leading icon or avatar. | |
| textClassName? | string | Extra classes for the label. | |
| className? | string | Extra classes for the container. | |
| selectedClassName? | string | Extra classes for the container applied only while `selected` — the escape hatch for accent-idiom fills (e.g. a lime pill on a dark canvas) that the primary-tinted selected variant can't express. | |
| selectedTextClassName? | string | Extra classes for the label applied only while `selected`. | |
| asyncTracking? | boolean | Auto-track async onPress — when onPress returns a Promise, the chip automatically enters loading state until it resolves. Default true. | |
| children? | React.ReactNode |
Compact selectable pill with press scale and ink overlay.
import { ChipFilters } from "@/components/ui/chip/variants/filters";
export function Example() {
return (
<ChipFilters />
);
} import * as React from "react";
import { ScrollView, View } from "react-native";
import { Chip } from "../chip";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** ChipFilters — horizontal filter rail (scrolls, never wraps) plus a dense
* sm rail for secondary constraints. */
export function ChipFilters() {
const [active, setActive] = React.useState("Ramen");
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-4 rounded-2xl p-4">
<Text variant="small" className="font-semibold">Search filters · Nihon Street, Kyoto</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8, paddingRight: 8 }}
>
{["Ramen", "Sushi", "Poke", "Matcha", "Karaage", "Izakaya"].map((tag) => (
<Chip
key={tag}
variant="filter"
selected={active === tag}
onPress={() => setActive(tag)}
>
{tag}
</Chip>
))}
<Chip onPress={() => {}} disabled>Closed now</Chip>
</ScrollView>
<View className="gap-2 border-t border-border pt-3">
<Text variant="small" className="font-semibold">Refine — size="sm"</Text>
<View className="flex-row gap-1.5">
{["Under 30 min", "4.5+", "Open now"].map((tag) => (
<Chip key={tag} size="sm" variant="filter">
{tag}
</Chip>
))}
</View>
</View>
</Glass>
</View>
);
}
Compact selectable pill with press scale and ink overlay.
import { ChipGlass } from "@/components/ui/chip/variants/glass";
export function Example() {
return (
<ChipGlass />
);
} import * as React from "react";
import { View, Text, ScrollView } from "react-native";
import { Chip } from "../chip";
import { Ambient } from "../../glass/ambient";
/** ChipGlass — glass filter pills floating over a forest trail. */
export function ChipGlass() {
const [sel, setSel] = React.useState("All");
const opts = ["All", "Saved", "Near me", "Trails", "Campsites"];
return (
<View className="relative h-[420px] w-full overflow-hidden rounded-3xl">
<Ambient seed="chip" />
<View className="absolute inset-x-0 bottom-6">
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ paddingHorizontal: 16, gap: 8 }}>
{opts.map((o) => (
<Chip key={o} variant="glass" selected={sel === o} onSelectedChange={() => setSel(o)}>
{o}
</Chip>
))}
</ScrollView>
</View>
<View className="absolute left-5 top-5">
<Text className="text-[10px] font-semibold uppercase tracking-widest text-white/90">
GLASS FILTERS
</Text>
</View>
</View>
);
}
Compact selectable pill with press scale and ink overlay.
import { ChipRemovable } from "@/components/ui/chip/variants/removable";
export function Example() {
return (
<ChipRemovable />
);
} import * as React from "react";
import { View } from "react-native";
import { Chip } from "../chip";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** ChipRemovable — saved tags with delete affordance. */
export function ChipRemovable() {
const [tags, setTags] = React.useState(["Vegan", "Spicy", "Ramen", "Matcha", "Karaage"]);
const remove = (tag: string) => setTags((t) => t.filter((x) => x !== tag));
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<Text variant="small">Saved dietary tags</Text>
<View className="flex-row flex-wrap gap-2">
{tags.map((tag) => (
<Chip key={tag} variant="suggestion" onDelete={() => remove(tag)}>
{tag}
</Chip>
))}
</View>
</Glass>
</View>
);
}