Kinetik UI

Float Field

.md

A text field whose label rises to the top edge on focus, with a ring that fades in and a shake for errors.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add float-field

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/float-field.json

Install manually

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/float-field.tsx
    View source files

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 TextInput prop (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

PropTypeDefaultDescription
labelrequiredstring—Sits inside the empty field and floats up to the top edge once it has focus or text.
valuerequiredstring—
onChangeTextrequired(text: string) => void—
errorstring—Shown in red under the field; the field shakes once each time a new error appears.
hintstring—Helper text under the field, replaced by the error while there is one.
styleStyleProp<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 },
});