Inline Edit
Text you rename in place: it becomes a field without moving, an underline draws in, and a check confirms the save.
import { Canvas, Path, Skia, type SkPath } from '@shopify/react-native-skia';
import { useEffect, useRef, useState } from 'react';
import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
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, spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';
export type InlineEditProps = {
value: string;
/** Called with the trimmed text when an edit is saved. Empty edits are dropped. */
onSubmit: (value: string) => void;
/** What the text is, for screen readers, e.g. "Project name". */
accessibilityLabel: string;
/** Shown while the text is empty. */
placeholder?: string;
/** Style for the text, shared by the label and the field so nothing moves. */
textStyle?: StyleProp<TextStyle>;
/** Defaults to 80. */
maxLength?: number;
/** A light tap when an edit is saved. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const ICON = 16;
const PENCIL = Skia.Path.MakeFromSVGString(
'M10.5 3.2l2.3 2.3M3 13l.6-2.9 7.6-7.6a1.2 1.2 0 0 1 1.7 0l.6.6a1.2 1.2 0 0 1 0 1.7l-7.6 7.6L3 13z',
)!;
const CHECK = Skia.Path.MakeFromSVGString('M3.2 8.4l3.1 3.1 6.5-6.9')!;
const CROSS = Skia.Path.MakeFromSVGString('M4 4l8 8M12 4l-8 8')!;
/**
* Text you can rename where it stands. Tapping it turns the same line into a
* field without moving a pixel, an underline draws in from the left and the
* save and cancel buttons pop in beside it; saving puts the text back with a
* brief check.
*/
export function InlineEdit({
value,
onSubmit,
accessibilityLabel,
placeholder = 'Untitled',
textStyle,
maxLength = 80,
haptics = true,
testID,
style,
}: InlineEditProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [saved, setSaved] = useState(0);
// Read in the blur handler: a blur after Save or Cancel must not save again.
const mode = useRef<'idle' | 'editing'>('idle');
const open = useSharedValue(0);
const flash = useSharedValue(0);
useEffect(() => {
open.set(withSpring(editing ? 1 : 0, spring('snappy', reduced)));
}, [editing, reduced, open]);
useEffect(() => {
if (saved === 0) return;
flash.set(1);
// A fade, not movement, so it plays with reduced motion too.
flash.set(withDelay(900, withTiming(0, timing('slow'))));
const id = setTimeout(() => setSaved(0), 900 + durations.slow);
return () => clearTimeout(id);
}, [saved, flash]);
const start = () => {
mode.current = 'editing';
setDraft(value);
setEditing(true);
};
const finish = (save: boolean) => {
if (mode.current !== 'editing') return;
mode.current = 'idle';
setEditing(false);
const next = draft.trim();
if (!save || next.length === 0 || next === value) return;
haptic('tap');
onSubmit(next);
setSaved((n) => n + 1);
};
const text = StyleSheet.flatten([
{
color: colors.text,
fontSize: font.size.xl,
fontWeight: font.weight.semibold,
lineHeight: Math.round(font.size.xl * 1.3),
},
textStyle,
]);
const underline = useAnimatedStyle(() => ({ transform: [{ scaleX: open.get() }] }));
const actions = useAnimatedStyle(() => ({
opacity: open.get(),
transform: [{ scale: 0.6 + open.get() * 0.4 }],
}));
const pencil = useAnimatedStyle(() => ({ opacity: 1 - flash.get() }));
const check = useAnimatedStyle(() => ({
opacity: flash.get(),
transform: [{ scale: 0.5 + flash.get() * 0.5 }],
}));
return (
<View testID={testID} style={[styles.row, style]}>
<View style={styles.grow}>
{editing ? (
<TextInput
value={draft}
onChangeText={setDraft}
autoFocus
selectTextOnFocus
maxLength={maxLength}
returnKeyType="done"
onSubmitEditing={() => finish(true)}
onBlur={() => finish(true)}
accessibilityLabel={accessibilityLabel}
placeholder={placeholder}
placeholderTextColor={colors.textSubtle}
selectionColor={colors.azure}
cursorColor={colors.azure}
style={[text, styles.input, { minHeight: minTouchTarget }]}
/>
) : (
<Pressable
onPress={start}
accessibilityRole="button"
accessibilityLabel={`${accessibilityLabel}, ${value || placeholder}`}
accessibilityHint="Edits the text"
style={styles.label}
>
<Text
numberOfLines={1}
style={[text, styles.shrink, !value && { color: colors.textSubtle }]}
>
{value || placeholder}
</Text>
<View style={styles.badge}>
<Animated.View style={[StyleSheet.absoluteFill, pencil]}>
<Glyph path={PENCIL} color={colors.textSubtle} />
</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, check]}>
<Glyph path={CHECK} color={colors.success} width={2} />
</Animated.View>
</View>
</Pressable>
)}
<Animated.View
pointerEvents="none"
style={[styles.underline, { backgroundColor: colors.azure }, underline]}
/>
</View>
{editing ? (
<Animated.View style={[styles.actions, actions]}>
<Pressable
onPress={() => finish(false)}
accessibilityRole="button"
accessibilityLabel="Cancel"
style={({ pressed }) => [
styles.action,
{ backgroundColor: pressed ? colors.surfaceSunken : colors.surface },
]}
>
<Glyph path={CROSS} color={colors.textMuted} />
</Pressable>
<Pressable
onPress={() => finish(true)}
accessibilityRole="button"
accessibilityLabel="Save"
style={({ pressed }) => [
styles.action,
{ backgroundColor: colors.azure, opacity: pressed ? 0.8 : 1 },
]}
>
<Glyph path={CHECK} color={colors.onAzure} width={2} />
</Pressable>
</Animated.View>
) : null}
</View>
);
}
function Glyph({ path, color, width = 1.6 }: { path: SkPath; color: string; width?: number }) {
return (
<Canvas style={styles.glyph}>
<Path
path={path}
style="stroke"
strokeWidth={width}
strokeCap="round"
strokeJoin="round"
color={color}
/>
</Canvas>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: minTouchTarget },
grow: { flex: 1, justifyContent: 'center' },
label: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
minHeight: minTouchTarget,
alignSelf: 'flex-start',
maxWidth: '100%',
},
shrink: { flexShrink: 1 },
input: { padding: 0, margin: 0, includeFontPadding: false },
badge: { width: ICON, height: ICON },
underline: {
position: 'absolute',
left: 0,
right: 0,
bottom: 4,
height: 2,
borderRadius: 1,
transformOrigin: 'left center',
},
actions: { flexDirection: 'row', gap: 6 },
action: {
width: minTouchTarget,
height: minTouchTarget,
borderRadius: minTouchTarget / 2,
alignItems: 'center',
justifyContent: 'center',
},
glyph: { width: ICON, height: ICON },
});Installation
npx kinetik-ui add inline-editpnpm dlx kinetik-ui add inline-editbunx kinetik-ui add inline-edityarn dlx kinetik-ui add inline-editThe 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/inline-edit.json
Install manually
- Install dependencies
npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/inline-edit.tsx
Usage
import { InlineEdit } from '@/components/kinetik/inline-edit';
<InlineEdit accessibilityLabel="Project name" value={name} onSubmit={setName} />;When to use
- Names and titles people rename now and then: a project, a playlist, a board.
- Detail screens where a separate edit form would be heavier than the change.
When not to use
- Several related fields that should be saved together. Use a form with one Save button.
- Text that needs validation with an explanation. Use Float Field, which has room for an error.
Notes
- The label and the field share
textStyle, so the text does not move when editing starts. - Return or Save keeps the trimmed text, Cancel restores the old one, and leaving the field saves it. An empty edit is dropped.
- Inside a
ScrollView, setkeyboardShouldPersistTaps="handled"so the buttons work while the keyboard is open.
Props
InlineEditProps
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | |
onSubmitrequired | (value: string) => void | — | Called with the trimmed text when an edit is saved. Empty edits are dropped. |
accessibilityLabelrequired | string | — | What the text is, for screen readers, e.g. "Project name". |
placeholder | string | 'Untitled' | Shown while the text is empty. |
textStyle | StyleProp<TextStyle> | — | Style for the text, shared by the label and the field so nothing moves. |
maxLength | number | 80 | Defaults to 80. |
haptics | boolean | true | A light tap when an edit is saved. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One native text input while editing and two shared values; the icons are 16 pt Skia paths. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.11× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 149 MB app memory. p99 spikes are the keyboard opening when editing starts.
Real-device measurement pending.
Accessibility
- Reduced motion
- The underline and buttons appear without a spring; the check still fades.
- Screen readers
- A button that reads the label and the value; while editing, a text field plus Cancel and Save buttons.
- Touch target
- The text and both buttons are at least 44 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/inline-edit.tsx
import { Canvas, Path, Skia, type SkPath } from '@shopify/react-native-skia';
import { useEffect, useRef, useState } from 'react';
import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
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, spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';
export type InlineEditProps = {
value: string;
/** Called with the trimmed text when an edit is saved. Empty edits are dropped. */
onSubmit: (value: string) => void;
/** What the text is, for screen readers, e.g. "Project name". */
accessibilityLabel: string;
/** Shown while the text is empty. */
placeholder?: string;
/** Style for the text, shared by the label and the field so nothing moves. */
textStyle?: StyleProp<TextStyle>;
/** Defaults to 80. */
maxLength?: number;
/** A light tap when an edit is saved. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const ICON = 16;
const PENCIL = Skia.Path.MakeFromSVGString(
'M10.5 3.2l2.3 2.3M3 13l.6-2.9 7.6-7.6a1.2 1.2 0 0 1 1.7 0l.6.6a1.2 1.2 0 0 1 0 1.7l-7.6 7.6L3 13z',
)!;
const CHECK = Skia.Path.MakeFromSVGString('M3.2 8.4l3.1 3.1 6.5-6.9')!;
const CROSS = Skia.Path.MakeFromSVGString('M4 4l8 8M12 4l-8 8')!;
/**
* Text you can rename where it stands. Tapping it turns the same line into a
* field without moving a pixel, an underline draws in from the left and the
* save and cancel buttons pop in beside it; saving puts the text back with a
* brief check.
*/
export function InlineEdit({
value,
onSubmit,
accessibilityLabel,
placeholder = 'Untitled',
textStyle,
maxLength = 80,
haptics = true,
testID,
style,
}: InlineEditProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [saved, setSaved] = useState(0);
// Read in the blur handler: a blur after Save or Cancel must not save again.
const mode = useRef<'idle' | 'editing'>('idle');
const open = useSharedValue(0);
const flash = useSharedValue(0);
useEffect(() => {
open.set(withSpring(editing ? 1 : 0, spring('snappy', reduced)));
}, [editing, reduced, open]);
useEffect(() => {
if (saved === 0) return;
flash.set(1);
// A fade, not movement, so it plays with reduced motion too.
flash.set(withDelay(900, withTiming(0, timing('slow'))));
const id = setTimeout(() => setSaved(0), 900 + durations.slow);
return () => clearTimeout(id);
}, [saved, flash]);
const start = () => {
mode.current = 'editing';
setDraft(value);
setEditing(true);
};
const finish = (save: boolean) => {
if (mode.current !== 'editing') return;
mode.current = 'idle';
setEditing(false);
const next = draft.trim();
if (!save || next.length === 0 || next === value) return;
haptic('tap');
onSubmit(next);
setSaved((n) => n + 1);
};
const text = StyleSheet.flatten([
{
color: colors.text,
fontSize: font.size.xl,
fontWeight: font.weight.semibold,
lineHeight: Math.round(font.size.xl * 1.3),
},
textStyle,
]);
const underline = useAnimatedStyle(() => ({ transform: [{ scaleX: open.get() }] }));
const actions = useAnimatedStyle(() => ({
opacity: open.get(),
transform: [{ scale: 0.6 + open.get() * 0.4 }],
}));
const pencil = useAnimatedStyle(() => ({ opacity: 1 - flash.get() }));
const check = useAnimatedStyle(() => ({
opacity: flash.get(),
transform: [{ scale: 0.5 + flash.get() * 0.5 }],
}));
return (
<View testID={testID} style={[styles.row, style]}>
<View style={styles.grow}>
{editing ? (
<TextInput
value={draft}
onChangeText={setDraft}
autoFocus
selectTextOnFocus
maxLength={maxLength}
returnKeyType="done"
onSubmitEditing={() => finish(true)}
onBlur={() => finish(true)}
accessibilityLabel={accessibilityLabel}
placeholder={placeholder}
placeholderTextColor={colors.textSubtle}
selectionColor={colors.azure}
cursorColor={colors.azure}
style={[text, styles.input, { minHeight: minTouchTarget }]}
/>
) : (
<Pressable
onPress={start}
accessibilityRole="button"
accessibilityLabel={`${accessibilityLabel}, ${value || placeholder}`}
accessibilityHint="Edits the text"
style={styles.label}
>
<Text
numberOfLines={1}
style={[text, styles.shrink, !value && { color: colors.textSubtle }]}
>
{value || placeholder}
</Text>
<View style={styles.badge}>
<Animated.View style={[StyleSheet.absoluteFill, pencil]}>
<Glyph path={PENCIL} color={colors.textSubtle} />
</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, check]}>
<Glyph path={CHECK} color={colors.success} width={2} />
</Animated.View>
</View>
</Pressable>
)}
<Animated.View
pointerEvents="none"
style={[styles.underline, { backgroundColor: colors.azure }, underline]}
/>
</View>
{editing ? (
<Animated.View style={[styles.actions, actions]}>
<Pressable
onPress={() => finish(false)}
accessibilityRole="button"
accessibilityLabel="Cancel"
style={({ pressed }) => [
styles.action,
{ backgroundColor: pressed ? colors.surfaceSunken : colors.surface },
]}
>
<Glyph path={CROSS} color={colors.textMuted} />
</Pressable>
<Pressable
onPress={() => finish(true)}
accessibilityRole="button"
accessibilityLabel="Save"
style={({ pressed }) => [
styles.action,
{ backgroundColor: colors.azure, opacity: pressed ? 0.8 : 1 },
]}
>
<Glyph path={CHECK} color={colors.onAzure} width={2} />
</Pressable>
</Animated.View>
) : null}
</View>
);
}
function Glyph({ path, color, width = 1.6 }: { path: SkPath; color: string; width?: number }) {
return (
<Canvas style={styles.glyph}>
<Path
path={path}
style="stroke"
strokeWidth={width}
strokeCap="round"
strokeJoin="round"
color={color}
/>
</Canvas>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: minTouchTarget },
grow: { flex: 1, justifyContent: 'center' },
label: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
minHeight: minTouchTarget,
alignSelf: 'flex-start',
maxWidth: '100%',
},
shrink: { flexShrink: 1 },
input: { padding: 0, margin: 0, includeFontPadding: false },
badge: { width: ICON, height: ICON },
underline: {
position: 'absolute',
left: 0,
right: 0,
bottom: 4,
height: 2,
borderRadius: 1,
transformOrigin: 'left center',
},
actions: { flexDirection: 'row', gap: 6 },
action: {
width: minTouchTarget,
height: minTouchTarget,
borderRadius: minTouchTarget / 2,
alignItems: 'center',
justifyContent: 'center',
},
glyph: { width: ICON, height: ICON },
});