Expokit
AboutDocsComponentsChartsBlocksPricing
Sign inGet started
All components

Inputs

CalendarNewDate PickerNewFloating InputOTP InputPassword InputSearch BarSelectSliderStepperTag InputTextareaTime PickerNew

Selection

CheckboxChip GroupColor SwatchProRadio GroupRatingProSegmented ControlSwitch

Buttons

ButtonFloating Action ButtonProLike ButtonProMorph ButtonProShimmer ButtonProSlide to ConfirmProStateful Button

Feedback

AlertProgressSkeletonSpinnerToast

Overlays

Action SheetNewBottom SheetDialogDrawerNew

Display

AccordionAvatarBadgeCardCarouselProList ItemNewPagination DotsNewSwipeable RowProTimelineNew

Navigation

Bottom NavNewProgress StepsNewTabs

Motion

Number TickerProText Animation
Tag Input
Sponsor ExpoKit

Free stays free. Tip once — keep shipping.

Support
About
Docs
IntroductionWhat ExpoKit is and how it worksInstallationAdd ExpoKit to an Expo appThemingColours, radius and dark modeCLIinit, add, login and updateAI assistants (MCP)Cursor, Claude, Antigravity and moreRun on your phoneTry components in Expo GoChangelogWhat shipped recently
npx @expokit/cli@latest initQuick start
Components
Liquid Glass8 iOS 26 glass pieces3DNew14 tilt, flip and stack
Inputs 12
  • CalendarNew
  • Date PickerNew
  • Floating Input
  • OTP Input
  • Password Input
  • +7 more
Selection 7
  • Checkbox
  • Chip Group
  • Color SwatchPro
  • Radio Group
  • RatingPro
  • +2 more
Buttons 7
  • Button
  • Floating Action ButtonPro
  • Like ButtonPro
  • Morph ButtonPro
  • Shimmer ButtonPro
  • +2 more
Feedback 5
  • Alert
  • Progress
  • Skeleton
  • Spinner
  • Toast
Overlays 4
  • Action SheetNew
  • Bottom Sheet
  • Dialog
  • DrawerNew
Display 9
  • Accordion
  • Avatar
  • Badge
  • Card
  • CarouselPro
  • +4 more
Navigation 3
  • Bottom NavNew
  • Progress StepsNew
  • Tabs
Motion 2
  • Number TickerPro
  • Text Animation
70 components · 347 live variantsAll components
Charts

Cartesian

  • Area Chart
  • Bar Chart
  • Line Chart
  • Combo Chart

Circular

  • Pie & Donut
  • Radial Chart
  • Radar Chart
  • Polar AreaPro

3D & Pixel

  • Isometric BarsPro
  • Pixel BarsPro

Specialty

  • TreemapPro
  • WaterfallPro
  • BulletPro
  • TimelinePro
  • Gauge
  • Candlestick
  • Heatmap
  • Funnel

Classic

  • Sparkline
  • Progress Rings
  • MultiLine (classic)
  • StackedBar (classic)
  • Ranking Bars (classic)

Guides

  • Usage & config
  • Patterns & effects
  • Show / hide anything
  • Chart theming
  • Interactions
23 charts, built on the open-source expo-charts engineAll charts
Blocks

Screens

ChatProAn AI assistant chat screenCheckoutProA premium checkoutDashboardProA finance / analytics home screenInboxProA messaging inboxOnboardingProA minimal swipeable welcome flowProfileProAccount profile for any appSearch ResultsProA clean, monochrome search screenSettingsProAn iOS-style grouped settings screenSign InProOne configurable sign-in flow modelled on top mobile apps
9 complete screens — drop one in, wire your dataSee them on devices All blocks
Pricing
DocsComponentsInputsTag Input

Tag Input

Scan with Expo Go

Tag Input with all its variants, native in Expo Go. No account needed.

No Expo Go? Open in your browser

Chips that spring in on Enter or comma and shrink away as neighbours slide over.

<TagInput />5 variantsiOS · Android · Expo Go
9:41

Sunday 18 October

9:41

Installation#

One command copies the source into your app — you own the code and can change anything.

Usage#

Colours, radius and light / dark come from useKit(). Wrap your app in <KitProvider accent="#6366f1" radius={14}> to re-skin every component at once.

Props#

PropTypeDefault
value / defaultValue

Controlled or uncontrolled tags.

string[][]
onChange

Called when a tag is added or removed (the removed chip then shrinks out).

(tags) => void—
variant

Field style.

"outlined" | "filled""outlined"
chipVariant

Tinted accent chips, filled accent chips, or bordered chips with an accent dot.

"soft" | "solid" | "outline""soft"
colorize

Tint each chip from a hash of its label.

booleanfalse
prefix

Shown before every chip label (e.g. "#"); typed copies are stripped.

string—
transform

Normalise a tag before validation (lowercase, strip spaces…).

(tag) => string—
maxTags

Limit; shows a filling ring and "3/5" counter, and locks the input when reached.

number—
suggestions

Pills under the empty field; while typing, a list with the match highlighted and an "Add …" row.

string[]—
separators

Characters that commit a tag (Enter always does).

string[][","]
validate

Reject a tag (the field shakes); return a string to show it as the error.

(tag, tags) => boolean | string—
error / helper

Message under the field; errors shake once.

string—
accent

Focus and chip colour.

stringtheme accent
style

Style for the outer wrapper around the field, suggestions and message (the one that shakes).

StyleProp<ViewStyle>—
…TextInputProps

placeholder, keyboardType, …

Behaviour & accessibility#

Keyboard
Enter or a separator commits the text as a chip. Backspace in an empty field highlights the last chip, and a second press removes it.
Motion
Chips pop in on a spring and shrink out on removal; duplicates bump and a rejected tag shakes the field.
Screen readers
Each chip has a 'Remove' button and each suggestion an 'Add' button; error messages land in a polite live region and the counter reads '3 of 5 tags'.
Web
The browser focus outline on the input is removed; the field draws its own focus state.

Source#

PreviousStepperNextTextarea

Did you like the content?

Coding agents

Chat apps

Other

9:41

Skills

Tag Input

Manual installation
  1. Install dependencies

    terminal
    npx expo install react-native-svg
  2. Copy the files

    Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.

    • src/components/ui/tag-input.tsx721 lines
    • src/lib/kit.tsx220 lines

    Want to read it first? Every file is in Source below.

  3. Use it

    App.tsx
    import { TagInput } from "@/components/ui/tag-input";

    See Usage for a full example.

Example.tsx
import { useState } from 'react';import { TagInput } from '@/components/ui/tag-input'; const [skills, setSkills] = useState(['TypeScript']);<TagInput value={skills} onChange={setSkills} placeholder="Add a skill…" suggestions={['React', 'Swift', 'Kotlin']} />

Every variant above has a complete, runnable example in its Code tab.

src/components/ui/tag-input.tsx
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState, type ComponentRef } from 'react';import {  Animated,  Easing,  Platform,  Pressable,  ScrollView,  StyleSheet,  Text,  TextInput,  View,  type StyleProp,  type TextInputProps,  type ViewStyle,} from 'react-native';import Svg, { Circle, Path } from 'react-native-svg'; import { alpha, motion, readableOn, useKit } from '@/lib/kit'; export type TagInputVariant = 'outlined' | 'filled';export type TagChipVariant = 'soft' | 'solid' | 'outline'; export interface TagInputProps  extends Omit<TextInputProps, 'style' | 'value' | 'defaultValue' | 'onChange' | 'onChangeText' | 'multiline'> {  value?: string[];  defaultValue?: string[];  onChange?: (tags: string[]) => void;  /** Field style. */  variant?: TagInputVariant;  /** Chip style. */  chipVariant?: TagChipVariant;  /** Tint each chip with a colour picked from a hash of its label. */  colorize?: boolean;  /** Text drawn before every chip label, e.g. '#'. Typed copies of it are stripped. */  prefix?: string;  /** Normalise a tag before it is validated and added (e.g. lowercase, strip spaces). */  transform?: (tag: string) => string;  /** Stop accepting tags after this many (a "3/5" counter appears under the field). */  maxTags?: number;  /** Tappable suggestions: a row of pills while the field is empty, a list with highlighted matches while typing. */  suggestions?: string[];  /** Characters that commit the typed text as a tag (Enter always does). Default [',']. */  separators?: string[];  /** Return false (or an error message) to reject a tag — the field shakes. */  validate?: (tag: string, tags: string[]) => boolean | string;  /** Hint under the field (hidden while an error is shown). */  helper?: string;  error?: string;  /** Focus and chip colour (default: theme accent). */  accent?: string;  style?: StyleProp<ViewStyle>;} const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();const PALETTE = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#0ea5e9', '#8b5cf6', '#ef4444', '#14b8a6'];const hashColor = (s: string) => {  let h = 0;  for (let i = 0; i < s.length; i++) h = (h * 31 + s.toLowerCase().charCodeAt(i)) | 0;  return PALETTE[Math.abs(h) % PALETTE.length];};/** * The order chips are drawn in: the current tags, plus tags that were just removed (and are still * shrinking out) slotted back in after the chip they used to follow. */const mergeOrder = (prev: string[], tags: string[]) => {  const out = [...tags];  prev.forEach((t, i) => {    if (out.includes(t)) return;    let at = 0;    for (let j = i - 1; j >= 0; j--) {      const k = out.indexOf(prev[j]);      if (k !== -1) {        at = k + 1;        break;      }    }    out.splice(at, 0, t);  });  return out;};const webInput = Platform.OS === 'web' ? ({ outlineStyle: 'none', outlineWidth: 0 } as object) : null; /** * Type and press Enter (or a comma) to turn text into chips. Chips pop in with a spring and * shrink out when removed; backspace on an empty field first highlights the last chip, a * second press removes it. Duplicates bump the existing chip, invalid tags shake the field, * and suggestions appear as pills or as a filtered list with the typed text highlighted. */export const TagInput = forwardRef<ComponentRef<typeof TextInput>, TagInputProps>(function TagInput(  {    value,    defaultValue,    onChange,    variant = 'outlined',    chipVariant = 'soft',    colorize,    prefix,    transform,    maxTags,    suggestions,    separators = [','],    validate,    helper,    error,    accent,    style,    placeholder = 'Add a tag…',    editable = true,    onFocus,    onBlur,    onKeyPress,    onSubmitEditing,    ...props  },  ref) {  const { colors, radius } = useKit();  const tint = accent ?? colors.accent;  const inputRef = useRef<ComponentRef<typeof TextInput>>(null);  useImperativeHandle(ref, () => inputRef.current as ComponentRef<typeof TextInput>);   const [inner, setInner] = useState<string[]>(defaultValue ?? []);  const tags = value ?? inner;  const tagsRef = useRef(tags);  tagsRef.current = tags;  const [text, setText] = useState('');  const [focused, setFocused] = useState(false);  // Removed tags stay in `order` until their chip has shrunk out, whether they were removed with  // the ✕, backspace, or by the parent changing `value`. Leaving = in `order` but not in `tags`.  const [order, setOrder] = useState<string[]>(tags);  const [synced, setSynced] = useState(tags);  let display = order;  if (synced !== tags && (synced.length !== tags.length || synced.some((t, i) => t !== tags[i]))) {    display = mergeOrder(order, tags);    setSynced(tags);    setOrder(display);  }  const leaving = display.filter((t) => !tags.includes(t));  const mounted = useRef(true);  const [bumps, setBumps] = useState<Record<string, number>>({});  const [armed, setArmed] = useState<string | null>(null); // last chip highlighted by the first backspace  const [rejected, setRejected] = useState<string>(); // message returned by validate  const full = maxTags !== undefined && tags.length >= maxTags;  const shownError = error ?? rejected;   const focus = useRef(new Animated.Value(0)).current; // JS: border colour  const halo = useRef(new Animated.Value(0)).current; // native: focus halo  const shake = useRef(new Animated.Value(0)).current; // native  useEffect(() => {    Animated.timing(focus, { toValue: focused ? 1 : 0, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();    Animated.spring(halo, { toValue: focused ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();  }, [focused, focus, halo]);  useEffect(() => {    mounted.current = true;    return () => {      mounted.current = false;      focus.stopAnimation();      halo.stopAnimation();      shake.stopAnimation();    };  }, [focus, halo, shake]);   const nudge = () => {    shake.setValue(0);    Animated.sequence(      [8, -6, 4, -2, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 50, useNativeDriver: true }))    ).start();  };  useEffect(() => {    if (error) nudge();    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [error]);   const commit = (next: string[]) => {    tagsRef.current = next;    if (value === undefined) setInner(next);    onChange?.(next);  };   const clean = (raw: string) => {    let tag = raw.trim();    if (prefix) while (tag.startsWith(prefix)) tag = tag.slice(prefix.length);    return transform ? transform(tag).trim() : tag;  };   /** Adds each candidate; returns true if anything was added. */  const add = (candidates: string[]) => {    let next = [...tagsRef.current];    let added = false;    setArmed(null);    for (const raw of candidates) {      const tag = clean(raw);      if (!tag) continue;      const exiting = leaving.find((t) => same(t, tag) && !next.includes(t));      if (exiting && (maxTags === undefined || next.length < maxTags)) {        // Re-added while shrinking out: put it back where it was so the chip grows back in place.        const before = display.slice(0, display.indexOf(exiting)).filter((t) => next.includes(t)).length;        next = [...next.slice(0, before), exiting, ...next.slice(before)];        added = true;        continue;      }      const existing = next.find((t) => same(t, tag));      if (existing) {        setBumps((b) => ({ ...b, [existing]: (b[existing] ?? 0) + 1 }));        continue;      }      if (maxTags !== undefined && next.length >= maxTags) {        nudge();        break;      }      const verdict = validate ? validate(tag, next) : true;      if (verdict !== true) {        if (typeof verdict === 'string') setRejected(verdict);        nudge();        continue;      }      setRejected(undefined);      next = [...next, tag];      added = true;    }    if (added) commit(next);    return added;  };   // The tag leaves `value` at once; its chip keeps rendering (see `order`) until it has shrunk out.  const remove = (tag: string) => {    setArmed(null);    if (tagsRef.current.includes(tag)) commit(tagsRef.current.filter((t) => t !== tag));  };  const exited = (tag: string) => {    if (!mounted.current) return;    setOrder((o) => o.filter((t) => t !== tag || tagsRef.current.includes(t)));  };   const onText = (next: string) => {    setArmed(null);    if (rejected) setRejected(undefined);    const sep = separators.find((s) => next.includes(s));    if (!sep) return setText(next);    const parts = next.split(new RegExp(`[${separators.map((s) => s.replace(/[\\\]^-]/g, '\\$&')).join('')}]`));    const rest = parts.pop() ?? '';    add(parts);    setText(rest);  };   const visible = tags;  const query = clean(text).toLowerCase();  const filtered = (suggestions ?? [])    .filter((s) => !visible.some((t) => same(t, s)))    .filter((s) => !query || s.toLowerCase().includes(query));  const typing = !!query && focused;  const exact = filtered.some((s) => same(s, clean(text)));   const active = shownError ? colors.danger : tint;  const borderColor = focus.interpolate({    inputRange: [0, 1],    outputRange: [shownError ? colors.danger : variant === 'filled' ? alpha(colors.input, 0) : colors.input, active],  });  const message = shownError ?? helper;   return (    <Animated.View style={[{ transform: [{ translateX: shake }] }, style]}>      <Pressable onPress={() => inputRef.current?.focus()} disabled={!editable} accessible={false} style={{ opacity: editable ? 1 : 0.5 }}>        <Animated.View          pointerEvents="none"          style={[            styles.halo,            {              borderRadius: radius.md + 4,              borderColor: alpha(active, 0.2),              opacity: halo,              transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.98, 1] }) }],            },          ]}        />        <Animated.View          style={[            styles.field,            { borderColor, borderRadius: radius.md, backgroundColor: variant === 'filled' ? colors.muted : colors.background },          ]}        >          {display.map((tag) => (            <Chip              key={tag}              label={tag}              prefix={prefix}              variant={chipVariant}              tint={colorize ? hashColor(tag) : tint}              leaving={leaving.includes(tag)}              armed={armed === tag}              bump={bumps[tag] ?? 0}              disabled={!editable}              onRemove={() => remove(tag)}              onExited={() => exited(tag)}            />          ))}          <TextInput            ref={inputRef}            accessibilityLabel={props.accessibilityLabel ?? placeholder}            autoCapitalize="none"            autoCorrect={false}            {...props}            value={text}            editable={editable && !full}            onChangeText={onText}            placeholder={full ? `Limit of ${maxTags} reached` : visible.length ? '' : placeholder}            placeholderTextColor={colors.mutedForeground}            selectionColor={tint}            cursorColor={tint}            submitBehavior="submit"            returnKeyType="done"            onSubmitEditing={(e) => {              add([text]);              setText('');              onSubmitEditing?.(e);            }}            onKeyPress={(e) => {              if (e.nativeEvent.key === 'Backspace' && !text && visible.length) {                const last = visible[visible.length - 1];                // First press highlights the last chip, the second removes it.                if (armed === last) remove(last);                else setArmed(last);              }              onKeyPress?.(e);            }}            onFocus={(e) => {              setFocused(true);              onFocus?.(e);            }}            onBlur={(e) => {              setFocused(false);              setArmed(null);              if (text.trim()) {                add([text]);                setText('');              }              onBlur?.(e);            }}            style={[styles.input, { color: colors.foreground }, full && styles.inputFull, webInput]}          />        </Animated.View>      </Pressable>       {typing && !full && (filtered.length || !exact) && suggestions?.length ? (        <Appear key="list" style={[styles.list, { backgroundColor: colors.popover, borderColor: colors.border, borderRadius: radius.md }]}>          {filtered.slice(0, 5).map((s) => {            const i = s.toLowerCase().indexOf(query);            return (              <Pressable                key={s}                accessibilityRole="button"                accessibilityLabel={`Add ${s}`}                onPress={() => {                  add([s]);                  setText('');                }}                style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => [                  styles.listRow,                  { borderRadius: radius.md - 4, backgroundColor: pressed || hovered ? colors.muted : 'transparent' },                ]}              >                <Glyph name="plus" size={16} color={tint} />                <Text numberOfLines={1} style={[styles.listText, { color: colors.foreground }]}>                  {prefix}                  {s.slice(0, i)}                  <Text style={[styles.match, { color: tint }]}>{s.slice(i, i + query.length)}</Text>                  {s.slice(i + query.length)}                </Text>              </Pressable>            );          })}          {!exact ? (            <Pressable              accessibilityRole="button"              onPress={() => {                add([text]);                setText('');              }}              style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => [                styles.listRow,                { borderRadius: radius.md - 4, backgroundColor: pressed || hovered ? colors.muted : 'transparent' },              ]}            >              <Glyph name="enter" size={16} color={colors.mutedForeground} />              <Text numberOfLines={1} style={[styles.listText, { color: colors.mutedForeground }]}>                Add “{prefix}                {clean(text)}”              </Text>            </Pressable>          ) : null}        </Appear>      ) : null}       {!typing && filtered.length && !full ? (        <ScrollView          horizontal          showsHorizontalScrollIndicator={false}          keyboardShouldPersistTaps="always"          style={styles.suggestions}          contentContainerStyle={styles.suggestionsRow}        >          {filtered.map((s) => (            <Pressable              key={s}              accessibilityRole="button"              accessibilityLabel={`Add ${s}`}              onPress={() => {                add([s]);                setText('');              }}              style={({ pressed }) => [                styles.suggestion,                { borderRadius: radius.capsule(34), borderColor: colors.border, backgroundColor: pressed ? colors.muted : 'transparent', transform: [{ scale: pressed ? 0.96 : 1 }] },              ]}            >              <Glyph name="plus" size={14} color={colorize ? hashColor(s) : tint} />              <Text style={[styles.suggestionText, { color: colors.foreground }]}>                {prefix}                {s}              </Text>            </Pressable>          ))}        </ScrollView>      ) : null}       {message || maxTags !== undefined ? (        <View style={styles.footer}>          {message ? (            <Appear key={`${shownError ? 'e' : 'h'}${message}`} style={styles.message} live={!!shownError}>              {shownError ? <Glyph name="alert" size={14} color={colors.danger} /> : null}              <Text style={[styles.helper, { color: shownError ? colors.danger : colors.mutedForeground }]}>{message}</Text>            </Appear>          ) : (            <View style={styles.flex} />          )}          {maxTags !== undefined ? <Counter count={visible.length} max={maxTags} tint={tint} /> : null}        </View>      ) : null}    </Animated.View>  );}); const GAP = 6; /** Fades and slides its children in once on mount. */function Appear({ children, style, live }: { children: React.ReactNode; style?: StyleProp<ViewStyle>; live?: boolean }) {  const enter = useRef(new Animated.Value(0)).current; // native  useEffect(() => {    Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();    return () => enter.stopAnimation();  }, [enter]);  return (    <Animated.View      accessibilityLiveRegion={live ? 'polite' : undefined}      style={[style, { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }] }]}    >      {children}    </Animated.View>  );} /** "3/5" with a small ring that fills as tags are added. */function Counter({ count, max, tint }: { count: number; max: number; tint: string }) {  const { colors } = useKit();  const full = count >= max;  const color = full ? colors.warning : tint;  const r = 6;  const c = 2 * Math.PI * r;  const pop = useRef(new Animated.Value(1)).current; // native  const first = useRef(true);  useEffect(() => {    if (first.current) {      first.current = false;      return;    }    pop.setValue(1.25);    Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();  }, [count, pop]);  useEffect(() => () => pop.stopAnimation(), [pop]);  return (    <Animated.View style={[styles.counter, { transform: [{ scale: pop }] }]} accessibilityLabel={`${count} of ${max} tags`}>      <Svg width={16} height={16} viewBox="0 0 16 16">        <Circle cx={8} cy={8} r={r} stroke={alpha(colors.mutedForeground, 0.25)} strokeWidth={2} fill="none" />        <Circle          cx={8}          cy={8}          r={r}          stroke={color}          strokeWidth={2}          fill="none"          strokeLinecap="round"          strokeDasharray={`${c} ${c}`}          strokeDashoffset={c * (1 - Math.min(1, count / max))}          transform="rotate(-90 8 8)"        />      </Svg>      <Text style={[styles.count, { color: full ? colors.warning : colors.mutedForeground }]}>        {count}/{max}      </Text>    </Animated.View>  );} function Chip({  label,  prefix,  variant,  tint,  leaving,  armed,  bump,  disabled,  onRemove,  onExited,}: {  label: string;  prefix?: string;  variant: TagChipVariant;  tint: string;  leaving: boolean;  armed: boolean;  bump: number;  disabled: boolean;  onRemove: () => void;  onExited: () => void;}) {  const { colors, radius, dark } = useKit();  // Inner chip (native driver): scale + fade. Outer wrapper (JS driver, separate view): width and  // the trailing gap collapse so the neighbours and the text input slide over.  const scale = useRef(new Animated.Value(0.85)).current; // native  const fade = useRef(new Animated.Value(0)).current; // native  const size = useRef(new Animated.Value(1)).current; // JS: 1 = full width, 0 = collapsed  const width = useRef(0);  const [frozen, setFrozen] = useState<number | null>(null); // measured width while collapsing   useEffect(() => {    Animated.parallel([      Animated.spring(scale, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),      Animated.spring(fade, { toValue: 1, useNativeDriver: true, ...motion.snappy }),    ]).start();    return () => {      scale.stopAnimation();      fade.stopAnimation();      size.stopAnimation();    };  }, [scale, fade, size]);   useEffect(() => {    if (!bump) return;    scale.setValue(1.18);    Animated.spring(scale, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();  }, [bump, scale]);   useEffect(() => {    if (!armed) return;    scale.setValue(1.1);    Animated.spring(scale, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();  }, [armed, scale]);   const wasLeaving = useRef(false);  useEffect(() => {    if (!leaving) {      if (!wasLeaving.current) return;      wasLeaving.current = false;      // Exit cancelled (tag re-added): grow back in place, then drop the fixed width.      scale.stopAnimation();      fade.stopAnimation();      size.stopAnimation();      Animated.parallel([        Animated.spring(scale, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),        Animated.spring(fade, { toValue: 1, useNativeDriver: true, ...motion.snappy }),      ]).start();      Animated.spring(size, { toValue: 1, useNativeDriver: false, ...motion.snappy }).start(({ finished }) => {        if (finished) setFrozen(null);      });      return;    }    wasLeaving.current = true;    if (width.current > 0) setFrozen((f) => f ?? width.current);    Animated.parallel([      Animated.timing(scale, { toValue: 0.8, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: true }),      Animated.timing(fade, { toValue: 0, duration: 150, easing: Easing.out(Easing.quad), useNativeDriver: true }),      Animated.timing(size, { toValue: 0, duration: 240, delay: 40, easing: Easing.inOut(Easing.cubic), useNativeDriver: false }),    ]).start(({ finished }) => {      // `finished` is false when stopped (unmount, or the exit was cancelled).      if (finished) onExited();    });    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [leaving]);   const look = armed    ? { bg: colors.danger, fg: colors.dangerForeground, border: colors.danger, dot: false }    : variant === 'solid'      ? { bg: tint, fg: readableOn(tint), border: tint, dot: false }      : variant === 'outline'        ? { bg: colors.background, fg: colors.foreground, border: alpha(tint, 0.45), dot: true }        : { bg: alpha(tint, dark ? 0.2 : 0.12), fg: tint, border: alpha(tint, dark ? 0.32 : 0.22), dot: false };   return (    <Animated.View      pointerEvents={leaving ? 'none' : 'auto'}      accessibilityElementsHidden={leaving}      importantForAccessibility={leaving ? 'no-hide-descendants' : 'auto'}      onLayout={(e) => {        if (frozen === null) width.current = e.nativeEvent.layout.width;      }}      style={        frozen !== null          ? {              overflow: 'hidden',              width: size.interpolate({ inputRange: [0, 1], outputRange: [0, frozen], extrapolate: 'clamp' }),              // Cancel the field's `gap` as the chip disappears so the next item lands flush.              marginRight: size.interpolate({ inputRange: [0, 1], outputRange: [-GAP, 0], extrapolate: 'clamp' }),            }          : null      }    >      <Animated.View        accessibilityState={{ selected: armed }}        style={[          styles.chip,          variant === 'solid' && !armed && styles.chipRaised,          {            backgroundColor: look.bg,            borderColor: look.border,            borderRadius: radius.capsule(30),            shadowColor: tint,            opacity: fade.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),            transform: [{ scale }],          },        ]}      >        {look.dot ? <View style={[styles.dot, { backgroundColor: tint }]} /> : null}        <Text numberOfLines={1} style={[styles.chipText, { color: look.fg }]}>          {prefix ? <Text style={styles.prefix}>{prefix}</Text> : null}          {label}        </Text>        <Pressable          hitSlop={{ top: 13, bottom: 13, left: 6, right: 12 }}          accessibilityRole="button"          accessibilityLabel={`Remove ${label}`}          disabled={disabled || leaving}          onPress={onRemove}          style={({ pressed }) => [styles.remove, { backgroundColor: alpha(look.fg === colors.foreground ? colors.mutedForeground : look.fg, pressed ? 0.3 : 0.16) }]}        >          <Glyph name="x" size={10} color={look.fg} strokeWidth={2.6} />        </Pressable>      </Animated.View>    </Animated.View>  );} function Glyph({ name, size, color, strokeWidth = 1.8 }: { name: 'x' | 'plus' | 'enter' | 'alert'; size: number; color: string; strokeWidth?: number }) {  const p = { stroke: color, strokeWidth, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };  return (    <Svg width={size} height={size} viewBox="0 0 24 24">      {name === 'x' ? <Path d="M6 6l12 12M18 6 6 18" {...p} /> : null}      {name === 'plus' ? <Path d="M12 5v14M5 12h14" {...p} strokeWidth={2.2} /> : null}      {name === 'enter' ? <Path d="M19 6v5a3 3 0 0 1-3 3H6m0 0 4-4m-4 4 4 4" {...p} /> : null}      {name === 'alert' ? (        <>          <Circle cx={12} cy={12} r={9} {...p} />          <Path d="M12 7.5v5.5M12 16.5v.01" {...p} strokeWidth={2.2} />        </>      ) : null}    </Svg>  );} const styles = StyleSheet.create({  halo: { position: 'absolute', top: -4, left: -4, right: -4, bottom: -4, borderWidth: 4 },  field: {    flexDirection: 'row',    flexWrap: 'wrap',    alignItems: 'center',    gap: GAP,    borderWidth: 1.5,    minHeight: 52,    paddingHorizontal: 10,    paddingVertical: 9,  },  input: { flexGrow: 1, flexBasis: 90, minWidth: 90, height: 30, fontSize: 16, paddingVertical: 0, paddingHorizontal: 4 },  inputFull: { flexBasis: 160 },  chip: { flexDirection: 'row', alignItems: 'center', height: 30, paddingLeft: 12, paddingRight: 6, gap: 6, borderWidth: 1 },  chipRaised: { shadowOpacity: 0.3, shadowRadius: 6, shadowOffset: { width: 0, height: 2 }, elevation: 2 },  dot: { width: 7, height: 7, borderRadius: 4, marginLeft: -2 },  chipText: { fontSize: 14, fontWeight: '400', maxWidth: 200 },  prefix: { opacity: 0.6 },  remove: { width: 18, height: 18, borderRadius: 9, alignItems: 'center', justifyContent: 'center' },  list: {    marginTop: 8,    padding: 4,    borderWidth: StyleSheet.hairlineWidth,    shadowColor: '#000',    shadowOpacity: 0.1,    shadowRadius: 14,    shadowOffset: { width: 0, height: 6 },    elevation: 6,  },  listRow: { flexDirection: 'row', alignItems: 'center', minHeight: 44, paddingHorizontal: 12, gap: 10 },  listText: { flex: 1, fontSize: 15 },  match: { fontWeight: '400' },  suggestions: { marginTop: 10, flexGrow: 0 },  suggestionsRow: { gap: 8, paddingRight: 4 },  suggestion: {    flexDirection: 'row',    alignItems: 'center',    height: 34,    paddingHorizontal: 12,    borderWidth: 1,    gap: 5,  },  suggestionText: { fontSize: 13.5, fontWeight: '400' },  footer: { flexDirection: 'row', alignItems: 'flex-start', marginTop: 6, gap: 12 },  flex: { flex: 1 },  message: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 5, marginLeft: 4 },  helper: { flex: 1, fontSize: 12.5 },  counter: { flexDirection: 'row', alignItems: 'center', gap: 5 },  count: { fontSize: 12.5, fontWeight: '400', fontVariant: ['tabular-nums'] },});