Kinetik UI

Morph Icon

.md

An icon button whose glyph reshapes between two states: menu to close, play to pause, plus to check.

v1.0.0Live on web@shopify/react-native-skiareact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add morph-icon

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/morph-icon.json

Install manually

  1. Install dependencies
    npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/morph-icon.tsx
    View source files

Usage

import { MorphIcon } from '@/components/kinetik/morph-icon';

<MorphIcon pair="menu-close" accessibilityLabel="Menu" value={open} onValueChange={setOpen} />

<MorphIcon
  pair="play-pause"
  variant="solid"
  accessibilityLabel="Play"
  activeAccessibilityLabel="Pause"
  value={playing}
  onValueChange={setPlaying}
/>

Four pairs ship: menu-close, play-pause, plus-close and plus-check. Leave value unset and the icon keeps its own state.

When to use

  • Controls that flip between two actions in place: play and pause, open and close a menu, add and added.
  • Toolbars and players, where the change of shape is the confirmation and no label is needed.

When not to use

  • Actions that do not reverse. A one-way action is a button, not a toggle.
  • Icons that must stay recognisable at a glance in dense lists. Use a static icon.

Notes

  • Each pair is drawn from strokes matched point for point, so the morph is a blend, not a crossfade. The menu's middle bar folds onto a diagonal instead of fading.
  • Set activeAccessibilityLabel when the action changes (Play / Pause). Without it the button is a toggle with a checked state (Menu, open or not).
  • With reduced motion the glyph switches instantly.

Props

MorphIconProps

PropTypeDefaultDescription
pairrequiredMorphIconPair—The two glyphs the icon moves between: off, then on.
accessibilityLabelrequiredstring—What the button does, e.g. "Menu". With `activeAccessibilityLabel` the label swaps instead ("Play" / "Pause") and the button is not a toggle.
valueboolean—Controlled state. Leave unset to let the icon keep its own.
defaultValuebooleanfalseInitial state when uncontrolled. Defaults to false.
onValueChange(next: boolean) => void—
activeAccessibilityLabelstring—Label while on, for buttons whose action changes rather than toggles.
variant'ghost' | 'soft' | 'solid''soft'`ghost` has no fill, `soft` a quiet one, `solid` an azure disc. Defaults to `soft`.
sizenumber48Diameter of the button. The glyph is half of it. Defaults to 48.
colorstring—Glyph colour. Defaults to the variant's ink.
hapticsbooleantrueA light tap as the state changes. Defaults to true.
disabledbooleanfalse
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
One tiny Skia canvas. The glyph is rebuilt from a dozen points on the UI thread, and only while it morphs.
Measured

Real-device measurement pending.

Accessibility

Reduced motion
The glyph switches without travelling or turning; the press still dips slightly.
Screen readers
A toggle button with a checked state, or a button whose label swaps (Play / Pause) when activeAccessibilityLabel is set.
Touch target
48 pt by default; smaller sizes get a hit slop that makes up 44 pt.

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/morph-icon.tsx
import { Canvas, Group, Path, Skia } from '@shopify/react-native-skia';
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
  useAnimatedStyle,
  useDerivedValue,
  useSharedValue,
  withSpring,
} from 'react-native-reanimated';

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

export type MorphIconPair = 'menu-close' | 'play-pause' | 'plus-close' | 'plus-check';

export type MorphIconProps = {
  /** The two glyphs the icon moves between: off, then on. */
  pair: MorphIconPair;
  /** Controlled state. Leave unset to let the icon keep its own. */
  value?: boolean;
  /** Initial state when uncontrolled. Defaults to false. */
  defaultValue?: boolean;
  onValueChange?: (next: boolean) => void;
  /**
   * What the button does, e.g. "Menu". With `activeAccessibilityLabel` the
   * label swaps instead ("Play" / "Pause") and the button is not a toggle.
   */
  accessibilityLabel: string;
  /** Label while on, for buttons whose action changes rather than toggles. */
  activeAccessibilityLabel?: string;
  /** `ghost` has no fill, `soft` a quiet one, `solid` an azure disc. Defaults to `soft`. */
  variant?: 'ghost' | 'soft' | 'solid';
  /** Diameter of the button. The glyph is half of it. Defaults to 48. */
  size?: number;
  /** Glyph colour. Defaults to the variant's ink. */
  color?: string;
  /** A light tap as the state changes. Defaults to true. */
  haptics?: boolean;
  disabled?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

type Glyph = {
  /** Strokes (or polygons when filled) on a 24 pt grid, as flat x, y lists. */
  off: readonly (readonly number[])[];
  /** The same strokes in the on state, point for point, so the two can be blended. */
  on: readonly (readonly number[])[];
  filled: boolean;
  /** Degrees the whole glyph turns on its way to the on state. */
  turn: number;
};

// The middle bar of the menu folds onto the first diagonal, so three bars
// become two without anything fading. Plus to close is the same plus, turned.
const GLYPHS: Record<MorphIconPair, Glyph> = {
  'menu-close': {
    off: [
      [4, 6.5, 20, 6.5],
      [4, 12, 20, 12],
      [4, 17.5, 20, 17.5],
    ],
    on: [
      [6, 6, 18, 18],
      [6, 6, 18, 18],
      [6, 18, 18, 6],
    ],
    filled: false,
    turn: 180,
  },
  'play-pause': {
    // The triangle is two quads that straighten into the two bars.
    off: [
      [7.5, 4.5, 12.5, 7.6, 12.5, 16.4, 7.5, 19.5],
      [12.5, 7.6, 19.5, 12, 19.5, 12, 12.5, 16.4],
    ],
    on: [
      [6.5, 5, 10.25, 5, 10.25, 19, 6.5, 19],
      [13.75, 5, 17.5, 5, 17.5, 19, 13.75, 19],
    ],
    filled: true,
    turn: 0,
  },
  'plus-close': {
    off: [
      [12, 5, 12, 19],
      [5, 12, 19, 12],
    ],
    on: [
      [12, 5, 12, 19],
      [5, 12, 19, 12],
    ],
    filled: false,
    turn: 135,
  },
  'plus-check': {
    off: [
      [12, 5, 12, 19],
      [5, 12, 19, 12],
    ],
    on: [
      [5, 12.5, 9.75, 17.25],
      [9.75, 17.25, 19, 7],
    ],
    filled: false,
    turn: 0,
  },
};

/**
 * An icon button whose glyph reshapes itself between two states: the menu
 * bars fold into a cross, play straightens into pause, a plus turns into a
 * cross or bends into a check. Every point travels on the UI thread.
 */
export function MorphIcon({
  pair,
  value,
  defaultValue = false,
  onValueChange,
  accessibilityLabel,
  activeAccessibilityLabel,
  variant = 'soft',
  size = 48,
  color,
  haptics = true,
  disabled = false,
  testID,
  style,
}: MorphIconProps) {
  const { colors } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [inner, setInner] = useState(defaultValue);
  const on = value ?? inner;

  const glyph = GLYPHS[pair];
  const box = size / 2;
  const unit = box / 24;
  const ink = color ?? (variant === 'solid' ? colors.onAzure : colors.text);
  const fill =
    variant === 'solid' ? colors.azure : variant === 'soft' ? colors.surfaceRaised : 'transparent';

  const t = useSharedValue(on ? 1 : 0);
  const pressed = useSharedValue(0);
  useEffect(() => {
    t.set(withSpring(on ? 1 : 0, spring('smooth', reduced)));
  }, [on, reduced, t]);

  const path = useDerivedValue(() => {
    const k = t.get();
    const b = Skia.PathBuilder.Make();
    for (let s = 0; s < glyph.off.length; s++) {
      const a = glyph.off[s]!;
      const z = glyph.on[s]!;
      for (let i = 0; i < a.length; i += 2) {
        const x = (a[i]! + (z[i]! - a[i]!) * k) * unit;
        const y = (a[i + 1]! + (z[i + 1]! - a[i + 1]!) * k) * unit;
        if (i === 0) b.moveTo(x, y);
        else b.lineTo(x, y);
      }
      if (glyph.filled) b.close();
    }
    return b.detach();
  });
  const spin = useDerivedValue(() => [{ rotate: (glyph.turn * t.get() * Math.PI) / 180 }]);

  const shell = useAnimatedStyle(() => ({
    transform: [{ scale: 1 - pressed.get() * 0.1 }],
  }));

  const toggle = () => {
    const next = !on;
    if (value === undefined) setInner(next);
    haptic('tap');
    onValueChange?.(next);
  };

  const swaps = activeAccessibilityLabel !== undefined;
  const slop = Math.max(0, (minTouchTarget - size) / 2);

  return (
    <Pressable
      testID={testID}
      disabled={disabled}
      onPress={toggle}
      onPressIn={() => pressed.set(withSpring(1, spring('snappy', reduced)))}
      onPressOut={() => pressed.set(withSpring(0, spring('bouncy', reduced)))}
      hitSlop={slop}
      accessibilityRole={swaps ? 'button' : 'togglebutton'}
      accessibilityLabel={swaps && on ? activeAccessibilityLabel : accessibilityLabel}
      accessibilityState={swaps ? { disabled } : { checked: on, disabled }}
      style={[{ width: size, height: size, opacity: disabled ? 0.45 : 1 }, style]}
    >
      <Animated.View
        style={[
          styles.disc,
          { borderRadius: size / 2, backgroundColor: fill },
          variant === 'soft' && {
            borderColor: colors.border,
            borderWidth: StyleSheet.hairlineWidth,
          },
          shell,
        ]}
      >
        {/* Wrapped: the web canvas ignores pointerEvents and would swallow the press. */}
        <View pointerEvents="none" style={{ width: box, height: box }}>
          <Canvas style={StyleSheet.absoluteFill}>
            <Group origin={{ x: box / 2, y: box / 2 }} transform={spin}>
              {glyph.filled ? (
                <>
                  <Path path={path} color={ink} />
                  {/* A thin stroke over the fill rounds the polygon corners. */}
                  <Path
                    path={path}
                    color={ink}
                    style="stroke"
                    strokeWidth={1.6 * unit}
                    strokeJoin="round"
                  />
                </>
              ) : (
                <Path
                  path={path}
                  color={ink}
                  style="stroke"
                  strokeWidth={2 * unit}
                  strokeCap="round"
                  strokeJoin="round"
                />
              )}
            </Group>
          </Canvas>
        </View>
      </Animated.View>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  disc: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});