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

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

## Install

```bash
npx kinetik-ui add float-field
```

With the shadcn CLI:

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

## Usage

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

## FloatFieldProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` |  | Sits inside the empty field and floats up to the top edge once it has focus or text. |
| `value` | `string` |  |  |
| `onChangeText` | `(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>` |  |  |

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

## Performance

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.

## Source

`components/kinetik/float-field.tsx`

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