Kinetik UI

Tag Field

.md

Typed words that pop into tags while the rest make room; a duplicate makes the existing tag jump instead.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add tag-field

The CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/tag-field.json

Install manually

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/tag-field.tsx
    View source files

Usage

import { TagField } from '@/components/kinetik/tag-field';

<TagField label="Topics" value={tags} onChange={setTags} maxTags={6} />;

When to use

  • Short free-form values people add several of: topics, skills, email recipients, labels.
  • Lists where people should see and edit everything they have added in one place.

When not to use

  • Choosing from a known, short list. Use Filter Chips.
  • A single value. Use Float Field.

Notes

  • Return or any of separators (a comma by default) ends a tag; pasted text with commas becomes several tags.
  • A duplicate (ignoring case) makes the existing tag jump instead of adding another; validate can refuse a tag, which shakes the field.
  • Backspace in the empty field first marks the last tag, then removes it. Additions and removals are announced to screen readers.
  • Each remove button is a full 44 pt square inside the 32 pt pill.

Props

TagFieldProps

PropTypeDefaultDescription
valuerequiredreadonly string[]—
onChangerequired(tags: string[]) => void—
labelrequiredstring—Shown above the field and used as the input's accessible name.
placeholderstring'Add a tag'
separatorsreadonly string[][',']Characters that end a tag as they are typed. Return always does. Defaults to [","].
maxTagsnumber—No more tags can be added past this.
validate(tag: string) => boolean—Return false to refuse a tag; the field shakes.
hapticsbooleantrueTicks as tags are added and removed. Defaults to true.
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
Plain views with Reanimated layout transitions, which run only when a tag is added or removed. Nothing runs at rest.
Measured

Pixel 9 emulator, API 35, release build · interaction median 0.94× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 137 MB app memory. p99 spikes are the keyboard opening and closing.

Real-device measurement pending.

Accessibility

Reduced motion
Tags appear and disappear at once and the rest move without a spring.
Screen readers
A text field named by its label whose hint lists the tags; each tag has a Remove button. Changes are announced.
Touch target
Each remove button is 44 by 44 pt; the field is at least 52 pt tall.

Try it on a device

Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.

Source files

components/kinetik/tag-field.tsx
import { useEffect, useRef, useState } from 'react';
import {
  AccessibilityInfo,
  Pressable,
  StyleSheet,
  Text,
  TextInput,
  View,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import Animated, {
  LinearTransition,
  ZoomIn,
  ZoomOut,
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withSpring,
  withTiming,
} from 'react-native-reanimated';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';

export type TagFieldProps = {
  value: readonly string[];
  onChange: (tags: string[]) => void;
  /** Shown above the field and used as the input's accessible name. */
  label: string;
  placeholder?: string;
  /** Characters that end a tag as they are typed. Return always does. Defaults to [","]. */
  separators?: readonly string[];
  /** No more tags can be added past this. */
  maxTags?: number;
  /** Return false to refuse a tag; the field shakes. */
  validate?: (tag: string) => boolean;
  /** Ticks as tags are added and removed. Defaults to true. */
  haptics?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const TAG_H = 32;

// Predefined animations with duration modifiers only, so they also run on the web.
const enter = ZoomIn.duration(durations.base).easing(easings.standard);
const leave = ZoomOut.duration(durations.quick).easing(easings.exit);
const reflow = LinearTransition.duration(durations.base).easing(easings.standard);

/**
 * Typed words that turn into tags. A tag pops in when Return or a comma ends
 * it, the others make room on a spring, and Backspace in the empty field
 * marks the last tag before removing it. A duplicate makes the existing tag
 * jump instead of adding another.
 */
export function TagField({
  value,
  onChange,
  label,
  placeholder = 'Add a tag',
  separators = [','],
  maxTags,
  validate,
  haptics = true,
  testID,
  style,
}: TagFieldProps) {
  const { colors, font, radius, space } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const input = useRef<TextInput>(null);
  const [draft, setDraft] = useState('');
  const [armed, setArmed] = useState(false);
  const [pulse, setPulse] = useState({ tag: '', n: 0 });
  const [focused, setFocused] = useState(false);
  const full = maxTags !== undefined && value.length >= maxTags;

  const shake = useSharedValue(0);
  const ring = useSharedValue(0);
  useEffect(() => {
    ring.set(withTiming(focused ? 1 : 0, timing('quick', reduced)));
  }, [focused, reduced, ring]);

  const refuse = () => {
    haptic('warning');
    if (reduced) return;
    const step = { duration: 45, easing: easings.inOut };
    shake.set(
      withSequence(
        withTiming(-5, step),
        withTiming(5, step),
        withTiming(-3, step),
        withSpring(0, spring('snappy')),
      ),
    );
  };

  const add = (raw: string): boolean => {
    const tag = raw.trim();
    if (!tag) return true;
    const existing = value.find((t) => t.toLowerCase() === tag.toLowerCase());
    if (existing) {
      setPulse((p) => ({ tag: existing, n: p.n + 1 }));
      haptic('tick');
      return true;
    }
    if (full || (validate && !validate(tag))) {
      refuse();
      return false;
    }
    haptic('tick');
    onChange([...value, tag]);
    AccessibilityInfo.announceForAccessibility(`${tag} added`);
    return true;
  };

  const remove = (tag: string) => {
    haptic('tick');
    setArmed(false);
    onChange(value.filter((t) => t !== tag));
    AccessibilityInfo.announceForAccessibility(`${tag} removed`);
  };

  const type = (text: string) => {
    setArmed(false);
    const cut = separators.find((s) => s && text.includes(s));
    if (!cut) {
      setDraft(text);
      return;
    }
    const parts = text.split(cut);
    const rest = parts.pop() ?? '';
    const ok = parts.every((p) => add(p));
    setDraft(ok ? rest : text.replace(cut, ''));
  };

  const shell = useAnimatedStyle(() => ({ transform: [{ translateX: shake.get() }] }));
  const outline = useAnimatedStyle(() => ({ opacity: ring.get() }));
  const last = value[value.length - 1];

  return (
    <View testID={testID} style={[{ gap: space[2] }, style]}>
      <View style={styles.header}>
        <Text
          style={{ color: colors.text, fontSize: font.size.sm, fontWeight: font.weight.medium }}
        >
          {label}
        </Text>
        {maxTags !== undefined ? (
          <Text
            style={{
              color: full ? colors.warning : colors.textSubtle,
              fontSize: font.size.xs,
              fontVariant: ['tabular-nums'],
            }}
          >
            {value.length}/{maxTags}
          </Text>
        ) : null}
      </View>
      <Animated.View
        style={[
          styles.field,
          {
            backgroundColor: colors.surface,
            borderColor: colors.borderStrong,
            borderRadius: radius.md,
            padding: space[2],
          },
          shell,
        ]}
      >
        <Animated.View
          pointerEvents="none"
          style={[styles.ring, { borderColor: colors.azure, borderRadius: radius.md + 1 }, outline]}
        />
        {value.map((tag) => (
          <Animated.View
            key={tag}
            entering={reduced ? undefined : enter}
            exiting={reduced ? undefined : leave}
            layout={reduced ? undefined : reflow}
            style={styles.slot}
          >
            <Tag
              tag={tag}
              armed={armed && tag === last}
              pulse={pulse.tag === tag ? pulse.n : 0}
              reduced={reduced}
              onRemove={() => remove(tag)}
            />
          </Animated.View>
        ))}
        <Animated.View layout={reduced ? undefined : reflow} style={styles.inputBox}>
          <TextInput
            ref={input}
            value={draft}
            onChangeText={type}
            onSubmitEditing={() => {
              if (add(draft)) setDraft('');
            }}
            submitBehavior="submit"
            onKeyPress={(e) => {
              if (e.nativeEvent.key !== 'Backspace' || draft.length > 0 || !last) return;
              if (armed) remove(last);
              else {
                haptic('tick');
                setArmed(true);
              }
            }}
            onFocus={() => setFocused(true)}
            onBlur={() => {
              setFocused(false);
              setArmed(false);
            }}
            editable={!full}
            accessibilityLabel={label}
            accessibilityHint={
              value.length > 0 ? `${value.length} tags: ${value.join(', ')}` : undefined
            }
            placeholder={full ? `Up to ${maxTags}` : value.length > 0 ? '' : placeholder}
            placeholderTextColor={colors.textSubtle}
            selectionColor={colors.azure}
            cursorColor={colors.azure}
            returnKeyType="done"
            autoCapitalize="none"
            style={[styles.input, { color: colors.text, fontSize: font.size.md }]}
          />
        </Animated.View>
      </Animated.View>
    </View>
  );
}

type TagProps = {
  tag: string;
  armed: boolean;
  pulse: number;
  reduced: boolean;
  onRemove: () => void;
};

function Tag({ tag, armed, pulse, reduced, onRemove }: TagProps) {
  const { colors, font, radius } = useKinetikTheme();
  const scale = useSharedValue(1);
  useEffect(() => {
    if (pulse === 0 || reduced) return;
    scale.set(withSequence(withTiming(1.12, { duration: 90 }), withSpring(1, spring('bouncy'))));
  }, [pulse, reduced, scale]);
  const motion = useAnimatedStyle(() => ({ transform: [{ scale: scale.get() }] }));

  return (
    // The row is a full 44 pt tall so the remove button can be; the pill drawn
    // inside it is 32 pt.
    <Animated.View style={[styles.tag, motion]}>
      <View
        style={[
          styles.pill,
          {
            backgroundColor: armed ? colors.azureSoft : colors.surfaceRaised,
            borderColor: armed ? colors.azure : colors.border,
            borderRadius: radius.pill,
          },
        ]}
      />
      <Text
        numberOfLines={1}
        style={[styles.tagText, { color: colors.text, fontSize: font.size.sm }]}
      >
        {tag}
      </Text>
      <Pressable
        onPress={onRemove}
        accessibilityRole="button"
        accessibilityLabel={`Remove ${tag}`}
        style={styles.remove}
      >
        {({ pressed }) => (
          <View
            style={[
              styles.cross,
              { backgroundColor: pressed ? colors.track : undefined, borderRadius: radius.pill },
            ]}
          >
            <View style={[styles.bar, { backgroundColor: colors.textMuted }, styles.barA]} />
            <View style={[styles.bar, { backgroundColor: colors.textMuted }, styles.barB]} />
          </View>
        )}
      </Pressable>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
  field: {
    borderWidth: 1,
    flexDirection: 'row',
    flexWrap: 'wrap',
    alignItems: 'center',
    gap: 6,
    minHeight: 52,
  },
  ring: { position: 'absolute', top: -1, left: -1, right: -1, bottom: -1, borderWidth: 2 },
  // Each tag's slot is a full 44 pt tall (so its remove button is), but takes
  // only the pill's 32 pt of layout; the margins live on the outermost view so
  // nothing above clips the target.
  slot: { marginVertical: -(minTouchTarget - TAG_H) / 2 },
  tag: {
    height: minTouchTarget,
    flexDirection: 'row',
    alignItems: 'center',
    paddingLeft: 12,
    maxWidth: 240,
  },
  pill: {
    position: 'absolute',
    left: 0,
    right: 0,
    top: (minTouchTarget - TAG_H) / 2,
    bottom: (minTouchTarget - TAG_H) / 2,
    borderWidth: 1,
  },
  tagText: { flexShrink: 1 },
  remove: {
    width: minTouchTarget,
    height: minTouchTarget,
    alignItems: 'center',
    justifyContent: 'center',
  },
  cross: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center' },
  bar: { position: 'absolute', width: 10, height: 1.6, borderRadius: 1 },
  barA: { transform: [{ rotate: '45deg' }] },
  barB: { transform: [{ rotate: '-45deg' }] },
  inputBox: { flexGrow: 1, minWidth: 96, marginVertical: -(minTouchTarget - TAG_H - 4) / 2 },
  input: { minHeight: minTouchTarget, paddingVertical: 0, paddingHorizontal: 6 },
});