# Grow Area

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

- Category: Inputs
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `@shopify/react-native-skia`, `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add grow-area
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/grow-area.json
```

## Usage

```tsx
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.

## GrowAreaProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` |  | Shown above the field and used as its accessible name. |
| `value` | `string` |  |  |
| `onChangeText` | `(text: string) => void` |  |  |
| `maxLength`? | `number` |  | A hard limit. A ring fills towards it and the count of characters left appears near the end. |
| `minLines`? | `number` | `3` | Height when empty, in lines. Defaults to 3. |
| `maxLines`? | `number` | `8` | The field stops growing and scrolls past this many lines. Defaults to 8. |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## 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.

## Performance

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.

## Source

`components/kinetik/grow-area.tsx`

```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 },
});
```
