Grow Area
A multi-line field that springs taller a line at a time, with a ring that fills towards the character limit.
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 },
});Installation
npx kinetik-ui add grow-areapnpm dlx kinetik-ui add grow-areabunx kinetik-ui add grow-areayarn dlx kinetik-ui add grow-areaThe 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
- Install dependencies
npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/grow-area.tsx
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 reachesmaxLines. - With
maxLengtha ring fills towards the limit; the number of characters left appears in the last 20 and turns red at zero. - Every other
TextInputprop is passed through.
Props
GrowAreaProps
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | Shown above the field and used as its accessible name. |
valuerequired | string | — | |
onChangeTextrequired | (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> | — |
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 },
});