Kinetik UI

Copy Chip

.md

A value with a copy button whose two pages fold into a check while a ring spreads and the outline turns green.

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

Installation

Kinetik CLI
npx kinetik-ui add copy-chip

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/copy-chip.json

Install manually

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

Usage

import * as Clipboard from 'expo-clipboard';

import { CopyChip } from '@/components/kinetik/copy-chip';

<CopyChip
  label="Invite link"
  value="kinetik.app/join/north-studio"
  onCopy={(v) => Clipboard.setStringAsync(v)}
/>;

Kinetik does not depend on a clipboard library. Pass the copy function you already use, such as setStringAsync from expo-clipboard (npx expo install expo-clipboard).

When to use

  • Values people paste somewhere else: invite links, promo codes, commands, account numbers, addresses.
  • Long values that are tedious to select by hand on a phone.

When not to use

  • Secrets that should not sit on the clipboard, such as one-time passwords for other devices.
  • Values people only need to read. Show them as text.

Notes

  • onCopy may return a promise. The chip confirms only once it resolves; if it rejects, an error haptic plays and nothing changes.
  • The copied state lasts resetAfter ms (1.6 s by default), then eases back.
  • The value is truncated to one line on screen; screen readers get all of it.
  • With reduced motion the glyph turns into a check in place.

Props

CopyChipProps

PropTypeDefaultDescription
valuerequiredstring—The text to show and copy, e.g. a command, a link or a code.
onCopyrequired(value: string) => void | Promise<unknown>—Does the copying, e.g. `(v) => Clipboard.setStringAsync(v)` from expo-clipboard.
labelstring—A short name read before the value, e.g. "Install command".
monospacebooleantrueShow the value in a monospaced face. Defaults to true.
resetAfternumber1600How long the copied state lasts, in ms. Defaults to 1600.
hapticsbooleantrueA success haptic on copy. Defaults to true.
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
Plain views and three shared values; the glyph is drawn with borders. Nothing runs at rest.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.15× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 148 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
The glyph turns into a check in place, without the ring.
Screen readers
A button named "Copy" plus the label and value, whose value reads "Copied" while copied; "Copied" is also announced.
Touch target
The whole chip is the target, 48 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/copy-chip.tsx
import { useEffect, useRef, useState } from 'react';
import {
  AccessibilityInfo,
  Platform,
  Pressable,
  StyleSheet,
  Text,
  View,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import Animated, {
  interpolateColor,
  useAnimatedStyle,
  useSharedValue,
  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 } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';

export type CopyChipProps = {
  /** The text to show and copy, e.g. a command, a link or a code. */
  value: string;
  /** Does the copying, e.g. `(v) => Clipboard.setStringAsync(v)` from expo-clipboard. */
  onCopy: (value: string) => void | Promise<unknown>;
  /** A short name read before the value, e.g. "Install command". */
  label?: string;
  /** Show the value in a monospaced face. Defaults to true. */
  monospace?: boolean;
  /** How long the copied state lasts, in ms. Defaults to 1600. */
  resetAfter?: number;
  /** A success haptic on copy. Defaults to true. */
  haptics?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const mono = Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' });

/**
 * A value with a copy button. Press it and the two pages of the copy glyph
 * fold into a check while a ring spreads from it and the outline turns
 * green, then it all eases back.
 */
export function CopyChip({
  value,
  onCopy,
  label,
  monospace = true,
  resetAfter = 1600,
  haptics = true,
  testID,
  style,
}: CopyChipProps) {
  const { colors, font, radius, space } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [copied, setCopied] = useState(false);
  const mounted = useRef(true);
  useEffect(
    () => () => {
      mounted.current = false;
    },
    [],
  );

  const done = useSharedValue(0);
  const ring = useSharedValue(0);
  const press = useSharedValue(0);

  useEffect(() => {
    done.set(withSpring(copied ? 1 : 0, spring(copied ? 'bouncy' : 'smooth', reduced)));
    if (!copied) return;
    ring.set(0);
    if (!reduced) ring.set(withTiming(1, { duration: durations.slow, easing: easings.standard }));
    const id = setTimeout(() => setCopied(false), resetAfter);
    return () => clearTimeout(id);
  }, [copied, reduced, resetAfter, done, ring]);

  const copy = async () => {
    try {
      await onCopy(value);
    } catch {
      haptic('error');
      return;
    }
    if (!mounted.current) return;
    haptic('success');
    setCopied(true);
    AccessibilityInfo.announceForAccessibility('Copied');
  };

  const shell = useAnimatedStyle(() => ({
    borderColor: interpolateColor(done.get(), [0, 1], [colors.border, colors.success]),
    transform: [{ scale: 1 - press.get() * 0.03 }],
  }));
  const pages = useAnimatedStyle(() => ({
    opacity: 1 - Math.min(1, done.get() * 1.6),
    transform: [
      { scale: 1 - Math.min(1, Math.max(0, done.get())) * 0.5 },
      { rotate: `${done.get() * -20}deg` },
    ],
  }));
  const check = useAnimatedStyle(() => ({
    opacity: Math.max(0, done.get() * 1.6 - 0.6),
    transform: [{ rotate: '45deg' }, { scale: 0.4 + Math.max(0, done.get()) * 0.6 }],
  }));
  const ripple = useAnimatedStyle(() => ({
    opacity: (1 - ring.get()) * 0.35 * (ring.get() > 0 ? 1 : 0),
    transform: [{ scale: 0.6 + ring.get() * 1.1 }],
  }));

  return (
    <Pressable
      testID={testID}
      onPress={copy}
      onPressIn={() => press.set(withSpring(1, spring('snappy', reduced)))}
      onPressOut={() => press.set(withSpring(0, spring('bouncy', reduced)))}
      accessibilityRole="button"
      accessibilityLabel={`Copy ${label ? `${label}: ` : ''}${value}`}
      accessibilityValue={copied ? { text: 'Copied' } : undefined}
      style={style}
    >
      <Animated.View
        style={[
          styles.shell,
          {
            backgroundColor: colors.surface,
            borderRadius: radius.md,
            paddingLeft: space[4],
            gap: space[2],
          },
          shell,
        ]}
      >
        <Text
          numberOfLines={1}
          style={[
            styles.value,
            {
              color: colors.text,
              fontSize: font.size.sm,
              fontFamily: monospace ? mono : undefined,
            },
          ]}
        >
          {value}
        </Text>
        <View style={styles.button}>
          <Animated.View style={[styles.ring, { borderColor: colors.success }, ripple]} />
          <Animated.View style={[styles.glyph, pages]}>
            <View style={[styles.page, styles.back, { borderColor: colors.textMuted }]} />
            <View
              style={[
                styles.page,
                styles.front,
                { borderColor: colors.text, backgroundColor: colors.surface },
              ]}
            />
          </Animated.View>
          <Animated.View style={[styles.check, { borderColor: colors.success }, check]} />
        </View>
      </Animated.View>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  shell: {
    flexDirection: 'row',
    alignItems: 'center',
    borderWidth: 1,
    minHeight: minTouchTarget + 4,
  },
  value: { flex: 1 },
  button: {
    width: minTouchTarget,
    height: minTouchTarget,
    alignItems: 'center',
    justifyContent: 'center',
  },
  ring: { position: 'absolute', width: 30, height: 30, borderRadius: 15, borderWidth: 2 },
  glyph: { width: 18, height: 18 },
  // Two pages: the back one peeks out above and to the left of the front one.
  page: { position: 'absolute', width: 12, height: 13, borderRadius: 3, borderWidth: 1.6 },
  back: { left: 0, top: 0 },
  front: { left: 5, top: 5 },
  check: {
    position: 'absolute',
    width: 7,
    height: 13,
    marginTop: -3,
    borderBottomWidth: 2.2,
    borderRightWidth: 2.2,
  },
});