Float Field
A text field whose label rises to the top edge on focus, with a ring that fades in and a shake for errors.
import { useEffect, useState } from 'react';
import {
AccessibilityInfo,
StyleSheet,
TextInput,
View,
type StyleProp,
type TextInputProps,
type ViewStyle,
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type FloatFieldProps = Omit<
TextInputProps,
'style' | 'placeholder' | 'value' | 'onChangeText' | 'multiline'
> & {
/** Sits inside the empty field and floats up to the top edge once it has focus or text. */
label: string;
value: string;
onChangeText: (text: string) => void;
/** Shown in red under the field; the field shakes once each time a new error appears. */
error?: string;
/** Helper text under the field, replaced by the error while there is one. */
hint?: string;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const HEIGHT = 56;
const LINE = 22;
/** How far the label rises: from the middle of the field to its top padding. */
const RISE = HEIGHT / 2 - 15;
const SMALL = 0.78;
/**
* A text field whose label lives inside it. On focus the label rises and
* shrinks to the top edge while a ring fades in around the field; an error
* turns the ring red, shakes the field once and slides its message in below.
*/
export function FloatField({
label,
value,
onChangeText,
error,
hint,
editable = true,
onFocus,
onBlur,
testID,
style,
...input
}: FloatFieldProps) {
const { colors, font, radius, space } = useKinetikTheme();
const reduced = useReduceMotion();
const [focused, setFocused] = useState(false);
const floated = focused || value.length > 0;
const invalid = Boolean(error);
const lift = useSharedValue(floated ? 1 : 0);
const ring = useSharedValue(0);
const shake = useSharedValue(0);
useEffect(() => {
lift.set(withSpring(floated ? 1 : 0, spring('snappy', reduced)));
}, [floated, reduced, lift]);
useEffect(() => {
ring.set(withTiming(focused ? 1 : 0, timing('quick', reduced)));
}, [focused, reduced, ring]);
useEffect(() => {
if (!error) return;
AccessibilityInfo.announceForAccessibility(error);
if (reduced) return;
const step = { duration: 45, easing: easings.inOut };
shake.set(
withSequence(
withTiming(-6, step),
withTiming(6, step),
withTiming(-4, step),
withTiming(3, step),
withSpring(0, spring('snappy')),
),
);
}, [error, reduced, shake]);
const accent = invalid ? colors.danger : colors.azure;
const shell = useAnimatedStyle(() => ({ transform: [{ translateX: shake.get() }] }));
const outline = useAnimatedStyle(() => ({ opacity: invalid ? 1 : ring.get() }));
const labelMotion = useAnimatedStyle(() => {
const p = lift.get();
return {
color: interpolateColor(
ring.get(),
[0, 1],
[invalid ? colors.danger : colors.textMuted, accent],
),
transform: [{ translateY: -RISE * p }, { scale: 1 - (1 - SMALL) * p }],
};
});
const message = error ?? hint;
return (
<View testID={testID} style={[{ gap: space[1.5], opacity: editable ? 1 : 0.5 }, style]}>
<Animated.View
style={[
styles.field,
{
backgroundColor: colors.surface,
borderColor: colors.borderStrong,
borderRadius: radius.md,
},
shell,
]}
>
<Animated.View
pointerEvents="none"
style={[styles.ring, { borderColor: accent, borderRadius: radius.md + 1 }, outline]}
/>
<Animated.Text
pointerEvents="none"
numberOfLines={1}
accessibilityElementsHidden
importantForAccessibility="no"
style={[
styles.label,
{ left: space[4], right: space[4], fontSize: font.size.md, lineHeight: LINE },
labelMotion,
]}
>
{label}
</Animated.Text>
<TextInput
{...input}
value={value}
onChangeText={onChangeText}
editable={editable}
accessibilityLabel={label}
accessibilityHint={message}
onFocus={(e) => {
setFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
onBlur?.(e);
}}
selectionColor={accent}
cursorColor={accent}
style={[
styles.input,
{
color: colors.text,
fontSize: font.size.md,
paddingHorizontal: space[4],
},
]}
/>
</Animated.View>
{message ? (
<Message
key={message}
text={message}
color={invalid ? colors.danger : colors.textSubtle}
size={font.size.xs}
indent={space[4]}
reduced={reduced}
/>
) : null}
</View>
);
}
type MessageProps = { text: string; color: string; size: number; indent: number; reduced: boolean };
/** Each new message drops in from just above its place. */
function Message({ text, color, size, indent, reduced }: MessageProps) {
const shown = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
shown.set(withTiming(1, { duration: durations.base, easing: easings.standard }));
}, [shown]);
const motion = useAnimatedStyle(() => ({
opacity: shown.get(),
transform: [{ translateY: (1 - shown.get()) * -4 }],
}));
return (
<Animated.Text
accessibilityElementsHidden
importantForAccessibility="no"
style={[{ color, fontSize: size, paddingHorizontal: indent }, motion]}
>
{text}
</Animated.Text>
);
}
const styles = StyleSheet.create({
field: { height: HEIGHT, borderWidth: 1, justifyContent: 'center' },
ring: { position: 'absolute', top: -1, left: -1, right: -1, bottom: -1, borderWidth: 2 },
label: {
position: 'absolute',
top: (HEIGHT - LINE) / 2 - 1,
transformOrigin: 'left center',
},
input: { height: HEIGHT - 2, paddingTop: 20, paddingBottom: 4 },
});Installation
npx kinetik-ui add float-fieldpnpm dlx kinetik-ui add float-fieldbunx kinetik-ui add float-fieldyarn dlx kinetik-ui add float-fieldThe 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/float-field.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/float-field.tsx
Usage
import { FloatField } from '@/components/kinetik/float-field';
<FloatField
label="Email"
hint="We only use it for receipts."
error={invalid ? 'That does not look like an email address.' : undefined}
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>;When to use
- Forms where space is tight and every field needs a visible name: sign up, checkout, profile.
- Fields that validate on blur and need to say what is wrong right under the input.
When not to use
- Long text. Use Grow Area, which grows with what is typed.
- Search. A search field reads better with an icon and no floating label.
Notes
- Every other
TextInputprop (keyboard type, auto-complete, secure entry, return key) is passed through. - The label is the field's accessible name and the hint or error is its hint; a new error is also announced.
- With reduced motion the label still moves, without a spring, and the field does not shake.
Props
FloatFieldProps
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | Sits inside the empty field and floats up to the top edge once it has focus or text. |
valuerequired | string | — | |
onChangeTextrequired | (text: string) => void | — | |
error | string | — | Shown in red under the field; the field shakes once each time a new error appears. |
hint | string | — | Helper text under the field, replaced by the error while there is one. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- A native text input with three shared values: label, ring and shake. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.06× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 135 MB app memory. The run types into both fields; the janky frames are the keyboard opening and closing, not the field.
Real-device measurement pending.
Accessibility
- Reduced motion
- The label moves without a spring and the field does not shake.
- Screen readers
- A text field named by its label, with the hint or error as its hint; a new error is announced.
- Touch target
- The whole field is the target, 56 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/float-field.tsx
import { useEffect, useState } from 'react';
import {
AccessibilityInfo,
StyleSheet,
TextInput,
View,
type StyleProp,
type TextInputProps,
type ViewStyle,
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type FloatFieldProps = Omit<
TextInputProps,
'style' | 'placeholder' | 'value' | 'onChangeText' | 'multiline'
> & {
/** Sits inside the empty field and floats up to the top edge once it has focus or text. */
label: string;
value: string;
onChangeText: (text: string) => void;
/** Shown in red under the field; the field shakes once each time a new error appears. */
error?: string;
/** Helper text under the field, replaced by the error while there is one. */
hint?: string;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const HEIGHT = 56;
const LINE = 22;
/** How far the label rises: from the middle of the field to its top padding. */
const RISE = HEIGHT / 2 - 15;
const SMALL = 0.78;
/**
* A text field whose label lives inside it. On focus the label rises and
* shrinks to the top edge while a ring fades in around the field; an error
* turns the ring red, shakes the field once and slides its message in below.
*/
export function FloatField({
label,
value,
onChangeText,
error,
hint,
editable = true,
onFocus,
onBlur,
testID,
style,
...input
}: FloatFieldProps) {
const { colors, font, radius, space } = useKinetikTheme();
const reduced = useReduceMotion();
const [focused, setFocused] = useState(false);
const floated = focused || value.length > 0;
const invalid = Boolean(error);
const lift = useSharedValue(floated ? 1 : 0);
const ring = useSharedValue(0);
const shake = useSharedValue(0);
useEffect(() => {
lift.set(withSpring(floated ? 1 : 0, spring('snappy', reduced)));
}, [floated, reduced, lift]);
useEffect(() => {
ring.set(withTiming(focused ? 1 : 0, timing('quick', reduced)));
}, [focused, reduced, ring]);
useEffect(() => {
if (!error) return;
AccessibilityInfo.announceForAccessibility(error);
if (reduced) return;
const step = { duration: 45, easing: easings.inOut };
shake.set(
withSequence(
withTiming(-6, step),
withTiming(6, step),
withTiming(-4, step),
withTiming(3, step),
withSpring(0, spring('snappy')),
),
);
}, [error, reduced, shake]);
const accent = invalid ? colors.danger : colors.azure;
const shell = useAnimatedStyle(() => ({ transform: [{ translateX: shake.get() }] }));
const outline = useAnimatedStyle(() => ({ opacity: invalid ? 1 : ring.get() }));
const labelMotion = useAnimatedStyle(() => {
const p = lift.get();
return {
color: interpolateColor(
ring.get(),
[0, 1],
[invalid ? colors.danger : colors.textMuted, accent],
),
transform: [{ translateY: -RISE * p }, { scale: 1 - (1 - SMALL) * p }],
};
});
const message = error ?? hint;
return (
<View testID={testID} style={[{ gap: space[1.5], opacity: editable ? 1 : 0.5 }, style]}>
<Animated.View
style={[
styles.field,
{
backgroundColor: colors.surface,
borderColor: colors.borderStrong,
borderRadius: radius.md,
},
shell,
]}
>
<Animated.View
pointerEvents="none"
style={[styles.ring, { borderColor: accent, borderRadius: radius.md + 1 }, outline]}
/>
<Animated.Text
pointerEvents="none"
numberOfLines={1}
accessibilityElementsHidden
importantForAccessibility="no"
style={[
styles.label,
{ left: space[4], right: space[4], fontSize: font.size.md, lineHeight: LINE },
labelMotion,
]}
>
{label}
</Animated.Text>
<TextInput
{...input}
value={value}
onChangeText={onChangeText}
editable={editable}
accessibilityLabel={label}
accessibilityHint={message}
onFocus={(e) => {
setFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
onBlur?.(e);
}}
selectionColor={accent}
cursorColor={accent}
style={[
styles.input,
{
color: colors.text,
fontSize: font.size.md,
paddingHorizontal: space[4],
},
]}
/>
</Animated.View>
{message ? (
<Message
key={message}
text={message}
color={invalid ? colors.danger : colors.textSubtle}
size={font.size.xs}
indent={space[4]}
reduced={reduced}
/>
) : null}
</View>
);
}
type MessageProps = { text: string; color: string; size: number; indent: number; reduced: boolean };
/** Each new message drops in from just above its place. */
function Message({ text, color, size, indent, reduced }: MessageProps) {
const shown = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
shown.set(withTiming(1, { duration: durations.base, easing: easings.standard }));
}, [shown]);
const motion = useAnimatedStyle(() => ({
opacity: shown.get(),
transform: [{ translateY: (1 - shown.get()) * -4 }],
}));
return (
<Animated.Text
accessibilityElementsHidden
importantForAccessibility="no"
style={[{ color, fontSize: size, paddingHorizontal: indent }, motion]}
>
{text}
</Animated.Text>
);
}
const styles = StyleSheet.create({
field: { height: HEIGHT, borderWidth: 1, justifyContent: 'center' },
ring: { position: 'absolute', top: -1, left: -1, right: -1, bottom: -1, borderWidth: 2 },
label: {
position: 'absolute',
top: (HEIGHT - LINE) / 2 - 1,
transformOrigin: 'left center',
},
input: { height: HEIGHT - 2, paddingTop: 20, paddingBottom: 4 },
});