Kinetik UI

Grow Area

.md

A multi-line field that springs taller a line at a time, with a ring that fills towards the character limit.

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

Installation

Kinetik CLI
npx kinetik-ui add grow-area

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/grow-area.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/grow-area.tsx
    View source files

Usage

import { GrowArea } from '@/components/kinetik/grow-area';

<GrowArea
  label="About you"
  placeholder="A line or two about yourself"
  value={bio}
  onChangeText={setBio}
  maxLength={160}
  maxLines={6}
/>;

When to use

  • Free text whose length varies a lot: a bio, a comment, a note to the seller.
  • Text with a hard limit people should see coming before they hit it.

When not to use

  • A single line such as a name or an email. Use Float Field.
  • Long documents. Use a dedicated editor screen.

Notes

  • The field starts at minLines, springs taller a line at a time and scrolls once it reaches maxLines.
  • With maxLength a ring fills towards the limit; the number of characters left appears in the last 20 and turns red at zero.
  • Every other TextInput prop is passed through.

Props

GrowAreaProps

PropTypeDefaultDescription
labelrequiredstring—Shown above the field and used as its accessible name.
valuerequiredstring—
onChangeTextrequired(text: string) => void—
maxLengthnumber—A hard limit. A ring fills towards it and the count of characters left appears near the end.
minLinesnumber3Height when empty, in lines. Defaults to 3.
maxLinesnumber8The field stops growing and scrolls past this many lines. Defaults to 8.
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
A native text input in a box whose height springs; the ring is a tiny Skia canvas with one trimmed arc. 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 · 143 MB app memory. Measured while typing three long lines and deleting them; the keyboard stays open.

Real-device measurement pending.

Accessibility

Reduced motion
The field resizes and the ring fills without springs.
Screen readers
A multi-line text field named by its label, plus a count of characters left.
Touch target
The whole field is the target, at least three lines 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/grow-area.tsx
import { Canvas, Circle, Path, Skia } from '@shopify/react-native-skia';
import { useEffect, useState } from 'react';
import {
  StyleSheet,
  Text,
  TextInput,
  View,
  type StyleProp,
  type TextInputProps,
  type ViewStyle,
} from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from 'react-native-reanimated';

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

export type GrowAreaProps = Omit<
  TextInputProps,
  'style' | 'multiline' | 'value' | 'onChangeText' | 'maxLength'
> & {
  /** Shown above the field and used as its accessible name. */
  label: string;
  value: string;
  onChangeText: (text: string) => void;
  /** A hard limit. A ring fills towards it and the count of characters left appears near the end. */
  maxLength?: number;
  /** Height when empty, in lines. Defaults to 3. */
  minLines?: number;
  /** The field stops growing and scrolls past this many lines. Defaults to 8. */
  maxLines?: number;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const LINE = 22;
const PAD = 12;
const RING = 22;
const STROKE = 2.5;
/** The count of characters left shows once this few remain. */
const NEAR = 20;

const arc = (() => {
  const r = RING / 2 - STROKE;
  return Skia.PathBuilder.Make()
    .addArc({ x: RING / 2 - r, y: RING / 2 - r, width: r * 2, height: r * 2 }, -90, 359.99)
    .detach();
})();

/**
 * A multi-line field that grows with what is typed. The height springs a line
 * at a time up to a limit, then the text scrolls; a ring beside the label
 * fills towards the character limit and turns amber, then red, near the end.
 */
export function GrowArea({
  label,
  value,
  onChangeText,
  maxLength,
  minLines = 3,
  maxLines = 8,
  editable = true,
  onFocus,
  onBlur,
  testID,
  style,
  ...input
}: GrowAreaProps) {
  const { colors, font, radius, space } = useKinetikTheme();
  const reduced = useReduceMotion();
  const [focused, setFocused] = useState(false);
  const [content, setContent] = useState(0);

  const lo = minLines * LINE;
  const hi = Math.max(lo, maxLines * LINE);
  const target = Math.min(hi, Math.max(lo, content)) + PAD * 2;

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

  const used = maxLength ? Math.min(1, value.length / maxLength) : 0;
  const fill = useSharedValue(used);
  useEffect(() => {
    fill.set(withSpring(used, spring('snappy', reduced)));
  }, [used, reduced, fill]);

  const left = maxLength ? maxLength - value.length : 0;
  const tone = !maxLength
    ? colors.azure
    : left <= 0
      ? colors.danger
      : left <= NEAR
        ? colors.warning
        : colors.azure;

  const box = useAnimatedStyle(() => ({ height: height.get() }));
  const outline = useAnimatedStyle(() => ({ opacity: ring.get() }));

  return (
    <View testID={testID} style={[{ gap: space[2], opacity: editable ? 1 : 0.5 }, style]}>
      <View style={styles.header}>
        <Text
          style={{ color: colors.text, fontSize: font.size.sm, fontWeight: font.weight.medium }}
        >
          {label}
        </Text>
        {maxLength ? (
          <View
            style={styles.counter}
            accessible
            accessibilityLabel={`${Math.max(0, left)} characters left`}
          >
            {left <= NEAR ? (
              <Text
                style={{
                  color: tone,
                  fontSize: font.size.xs,
                  fontWeight: font.weight.semibold,
                  fontVariant: ['tabular-nums'],
                }}
              >
                {left}
              </Text>
            ) : null}
            <Canvas style={styles.ring}>
              <Circle
                cx={RING / 2}
                cy={RING / 2}
                r={RING / 2 - STROKE}
                style="stroke"
                strokeWidth={STROKE}
                color={colors.track}
              />
              <Path
                path={arc}
                style="stroke"
                strokeWidth={STROKE}
                strokeCap="round"
                color={tone}
                end={fill}
              />
            </Canvas>
          </View>
        ) : null}
      </View>
      <Animated.View
        style={[
          styles.field,
          {
            backgroundColor: colors.surface,
            borderColor: colors.borderStrong,
            borderRadius: radius.md,
            paddingHorizontal: space[4],
          },
          box,
        ]}
      >
        <Animated.View
          pointerEvents="none"
          style={[
            styles.focusRing,
            { borderColor: colors.azure, borderRadius: radius.md + 1 },
            outline,
          ]}
        />
        <TextInput
          {...input}
          multiline
          value={value}
          onChangeText={onChangeText}
          maxLength={maxLength}
          editable={editable}
          scrollEnabled={content > hi}
          accessibilityLabel={label}
          onFocus={(e) => {
            setFocused(true);
            onFocus?.(e);
          }}
          onBlur={(e) => {
            setFocused(false);
            onBlur?.(e);
          }}
          onContentSizeChange={(e) => setContent(Math.ceil(e.nativeEvent.contentSize.height))}
          placeholderTextColor={colors.textSubtle}
          selectionColor={colors.azure}
          cursorColor={colors.azure}
          textAlignVertical="top"
          style={[styles.input, { color: colors.text, fontSize: font.size.md, lineHeight: LINE }]}
        />
      </Animated.View>
    </View>
  );
}

const styles = StyleSheet.create({
  header: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    minHeight: RING,
  },
  counter: { flexDirection: 'row', alignItems: 'center', gap: 6 },
  ring: { width: RING, height: RING },
  field: { borderWidth: 1, paddingVertical: PAD - 1, overflow: 'hidden' },
  focusRing: { position: 'absolute', top: -1, left: -1, right: -1, bottom: -1, borderWidth: 2 },
  input: { flex: 1, padding: 0, margin: 0 },
});