Primitives
Animated Number Avatar Badge Button Button Group Toggle Toggle Group Checkbox Chip Countdown Divider Fab Grid Icon Image Input Search Input Textarea Label Loader Marquee Masked Input Message Bubble Notification Banner Offline Banner Progress Progress Ring Radio Group Range Slider Rating Separator Skeleton Slider Stack Stepper Switch Text Theme Toggle Ticker Typing IndicatorComposed
Accordion Achievement Badge Action Bar Action Sheet Action Swap Activity Rings Add to Cart Button Address Card Agenda List Alert Amount Keypad Animated Icons App Bar Audio Message Audio Recorder Autocomplete Avatar Group Balance Display Bar List Barcode Before After Biometric Gate Bloom Menu Bottom Sheet Paywall Sheet Side Sheet Breadcrumb Calendar Calendar Strip Card Carousel Cart Item Character Charts Chat Composer Chat List Item Choice Card Circle Reveal Coach Marks Collapsible Collapsible Header Color Picker Combobox Command Palette Comment Thread Confetti Consent Sheet Context Menu Copy Field Country Picker Coupon Input Currency Input Data Table Date Range Picker Day Schedule Dialog Dock Donut Chart Drawer Driver Card Dropdown Menu Drum Picker Emoji Picker Empty State Error State Event Card Expandable Control Fader Feature Gate File Picker Filter Bar Follow Button Form Gauge Glass Glass Cluster Glass Dock Morph Glass Fab Morph Glass Lab Glass Orb Glass Search Morph Glass Sheet Edge Gradient Button Headline New Heatmap Image Cropper Image Viewer Kanban Card Keyboard Avoiding Keyboard Toolbar Leaderboard Level Progress Lightbox Like Button Line Chart List Reorderable List Swipeable Row Location Card Location Search Map Sheet Map View Markdown View Media Controls Media Picker Media Tile Mention Input Meter Bar Mini Player Mood Slider Morphing Modal Multi Select Navigation Rail Notification Item Number Input Onboarding Pager Order Card Order Summary OTP Input Pagination Paper Clip New Parallax Header Pass Card Password Strength Payment Button Payment Card Payment Method Permission Primer Phone Input PIN Pad Polaroid New Poll Popover Post Card Postage Stamp New Price Tag Pricing Table Product Card Product Gallery Profile Header Promo Banner Prompt Input Property Card Pull To Refresh QR Code Quick Actions Quick Replies Rating Summary Reaction Picker Resend Timer Review Card Review Prompt Ride Option Save Button Scan Frame Scroll Spy Tabs Seat Map Section Header Section Index Segmented Control Select Share Button Share Sheet Shutter Button Signature Pad Slide to Confirm Slot Picker Smart Banner Social Login Button Sparkline Splash Screen Split Button Stat Card Stateful Button Status Dot Step Flow Steps Sticker New Stopwatch Stories Progress Stories Viewer Story Ring Streak Counter Streaming Text Success Seal Swipe Deck Tabs Tag Input Tape New Text Reveal Thinking Indicator Time Picker Timeline Timer Ring Tip Selector Toast Toolbar Tooltip Track Row Transaction Row Trip Progress Variant Picker Video Controls Waveform Weather Card Weekday Picker Zoom TransitionScreens
Account Switcher Biometric Setup Delete Account Forgot Password Grow Auth Login Login — Dark Stage Login — Dense Work Login — Editorial Login — Illustrated Login — Minimal Ink Login — Social Login — Video Loop Food Onboarding Onboarding Onboarding Dark Objects Onboarding Editorial Onboarding Illustrated Onboarding Personalize Onboarding Photo Carousel OTP Verification Passkey — Create Password Reset Permission — Notifications Phone Verification Onboarding Quiz Session Devices Signup Sign-up Chat Sign-up Photo Sheet Social Auth Split Auth Verify Identity Welcome AI Chat AI Assistant Channels List Chat Info Chat List Chat Media Chat Thread Create Group Group Info Message Requests New Message Kept Messages Accessibility Activity Log Appearance Authenticator Blocked Accounts Export Your Data Downloads Edit Profile Edit Profile — Rows Emergency Call Empty States Set Error State Send Feedback Device Locator Help Center Home — Bento Home — Dark Home — Editorial Home Feed Invite Friends Language & Region Linked Accounts Not Found (404) Notification Settings Notifications Password Vault Paywall Paywall — Comparison Paywall — Dark Stage Paywall — Editorial Paywall — Photo Paywall — Trial Timeline Privacy Profile Profile — Gamified Profile — Minimal Profile — Photo Hero QR Scanner Report Issue Saved Items Phone Time Search Search — Command Palette Search — Visual Account Health Settings Settings — Dark Device Settings — Search Storage & Data Terms & Privacy Dating Chat Dating Filters Profile Discover Its A Match Into You Matches Profile Detail Profile Editor Courier Matching Delivery History Delivery Support Package Schedule Parcel Tracking Proof Of Delivery Quick Aisle Quick Home Send Package Substitutions Add Payment Address Book Cart Categories Checkout Checkout — Steps Checkout Success Delivery Filters & Sort Flash Sale Gift Cards Loyalty Points Order Cancel Order Detail Orders Pay Later Checkout Payment Methods Product Detail Product Detail — Hero Product Grid Product Reviews Promo Codes Recently Viewed Return Items Search Results Shop Assistant Store Home Tip & Rate Courier Order Tracking Wishlist Write a Review Event Checkin Event Hero Events Discover My Tickets Seat Selection Ticket Checkout Add Card Bank Accounts Dashboard — Bento Bill Pay Budgets Card Details Crypto Portfolio Finance Dashboard Dashboard — Dark Exchange Currency Expense List Invoice Detail Manage Cards Plan Management Savings Goals Send Money Spending Analytics Split Bill Subscriptions Token Detail Token Swap Transactions Wallet Home Wallet Movers Courier Tracking Food Cart Food Home Food Orders Grocery List Recipe Detail Restaurant — Dark Restaurant Detail Activity Rings Fitness Home Habit Quest Habit Tracker Meal Planner Med Store Meditation Player Mood Check-in Nutrition Home Period Tracker Prior Auth Prior Auth Cases Quest Complete Run Tracking Rx Scan Sleep Tracker Telehealth Booking Walk Map Water Tracker Workout Complete Workout Detail Workout Session Applications Apply Company Interview Schedule Job Detail Job Feed Resume Saved Jobs Create Listing Item Chat Listing Detail Make Offer Market Checkout Market Home Market Search My Listings Offers Inbox Seller Profile Audiobook Player Camera Gallery Albums Game Store Mini Player Music Player Photo Editor Photo Gallery Playlist Detail Podcast Player Clips Feed Serene Home Sound Mixer Stories Video Call Video Player Article Reader Course Detail Event Detail Memory Onboarding Icon Lab Leaderboard Lesson Player Match Detail News Editorial News Home Plant Care Prediction Live Prediction Welcome Smart Home Standings Ticket Wallet Topics Follow Weather Detail Add Task Sheet Alarm Clock Calendar Day Calendar Month Doc Scanner File Manager Focus Timer Journal Kanban Board Mail Inbox Mail Thread Note Editor Notes List Reminders Task List Time Blocks Agent Profile Compare Homes Mortgage Calculator Property Detail Rental Application Saved Homes Schedule Tour Search List Search Map Complete Destination Driver Arriving Ev Charging History Home In Trip Matching Options Parking Receipt Safety Schedule Scooter Unlock Booking Confirmed Booking Review Booking Slots My Bookings Pro On The Way Provider Detail Provider List Rate Service Services Home Arc Create Arc Detail Arc Onboarding Avatar Creator Comments Sheet Create Post Explore Grid Followers List Hashtag Feed Mentions Post Detail Post Hero Social Profile Boarding Pass Booking Confirm Car Rental Trip Discover Flight Live Flight Preferences Flight Results Flight Search Hotel Detail Live Transit Trip — Map Day Trip — Rideshare Request Seat Select Travel Home Trip Itinerary Trip OnboardingNothing matches — try another word.
Form
Building blocks for forms and settings: labelled fields, sections, rows and messages. Labels can brighten once a value is valid, and row values update in place as they change.
Preview
import * as React from "react";
import { forwardRef, createContext, useContext, useId } from "react";
import { View, Text, Pressable, type ViewProps, type TextProps, type PressableProps, type GestureResponderEvent } from "react-native";
import Animated, {
Easing,
interpolateColor,
useSharedValue,
useAnimatedStyle,
withDelay,
withSpring,
withTiming,
} from "react-native-reanimated";
import { cn, ease, useHaptic, usePressAnimation, spring, timing, useReducedMotion, useTheme } from "@lumioui/core";
import { Icon } from "../icon";
/**
* Form — dependency-free form primitives.
* <Form>
* <Form.Field name="email" label="Email" required error={errors.email}>
* <Input />
* </Form.Field>
* </Form>
* Field composes Label + control + Message; all pieces are also usable standalone.
*
* Light, not decoration: a Field given `valid` keeps its label muted until
* the value passes, then the label switches on to ink — the only light a
* form needs. Row values swap in place (dim / ignite) and disabled rows
* crossfade instead of snapping.
*/
/** Ignite curve shared by the label switch-on and the row value swap. */
const IGNITE = { duration: timing.fast.duration, easing: Easing.bezier(...ease.out) };
/** A swapped row value starts at this brightness, then ignites (dim, not gone). */
const SWAP_DIM = 0.35;
/** Disabled rows rest at this brightness — reached by a crossfade. */
const ROW_DISABLED = 0.5;
interface FormContextValue {
/** Reserve space for future form-level state (submission, errors map, etc.). */
disabled?: boolean;
}
const FormContext = createContext<FormContextValue>({});
export function useFormContext(): FormContextValue {
return useContext(FormContext);
}
interface FormFieldContextValue {
name?: string;
controlId: string;
messageId: string;
hasError: boolean;
required: boolean;
/** True when the surrounding <Form disabled> — custom controls read it
* via useFormField() to disable themselves. */
disabled: boolean;
/** Set when the Field opts into the valid light (see FormFieldProps.valid). */
valid?: boolean;
/** Wait before the label first switches on, in ms. */
validDelay?: number;
}
const FormFieldContext = createContext<FormFieldContextValue | null>(null);
/** Access field wiring (ids, error state) inside custom controls. */
export function useFormField(): FormFieldContextValue {
const ctx = useContext(FormFieldContext);
if (!ctx) throw new Error("useFormField must be used within a Form.Field");
return ctx;
}
export interface FormProps extends ViewProps {
/** Disable all fields in the form (exposed via useFormContext). */
disabled?: boolean;
className?: string;
}
const FormRoot = forwardRef<React.ElementRef<typeof View>, FormProps>(
function Form({ disabled, className, ...props }, ref) {
return (
<FormContext.Provider value={{ disabled }}>
<View ref={ref} className={cn("w-full gap-4", className)} {...props} />
</FormContext.Provider>
);
}
);
export interface FormFieldProps extends ViewProps {
/** Field name (for identification/analytics). */
name?: string;
/** Shorthand label — equivalent to rendering <Form.Label>. */
label?: string;
/** Show a required asterisk on the label. */
required?: boolean;
/** Error message — renders <Form.Message> and marks the field invalid. */
error?: string;
/** Hint text shown when there is no error. */
description?: string;
/** Opt into the valid light: the label stays muted while false and
* switches on to ink when true. Omit to keep the label always ink. */
valid?: boolean;
/** Delay before the label's first switch-on, so pre-filled fields light
* in reading order (e.g. index × 60). Later changes are immediate. */
validDelay?: number;
className?: string;
labelClassName?: string;
}
/** Props FormField injects into a lone control child — typed so the
* cloneElement call needs no `as never`. */
interface WiredControlProps {
"aria-invalid"?: boolean;
"aria-labelledby"?: string;
"aria-describedby"?: string;
"aria-disabled"?: boolean;
/** TextInput-style controls (Input/Textarea/SearchInput). */
editable?: boolean;
/** Pressable-style controls (Checkbox/Switch/Select). */
disabled?: boolean;
}
const FormField = forwardRef<React.ElementRef<typeof View>, FormFieldProps>(
function FormField(
{ name, label, required = false, error, description, valid, validDelay, className, labelClassName, children, ...props },
ref
) {
const baseId = useId();
const formCtx = useContext(FormContext);
const ctx: FormFieldContextValue = {
name,
controlId: `${baseId}-control`,
messageId: `${baseId}-message`,
hasError: error != null && error !== "",
required,
disabled: formCtx.disabled ?? false,
valid,
validDelay,
};
// Wire the control to its label/message: a lone element child gets
// aria-invalid plus the label/message ids (they're rendered as real DOM
// ids via nativeID), and inherits <Form disabled>. Multi-node children
// are left alone — custom slots can wire themselves through useFormField().
const control =
React.isValidElement(children) && !Array.isArray(children)
? React.cloneElement(children as React.ReactElement<WiredControlProps>, {
"aria-invalid": ctx.hasError || undefined,
"aria-labelledby": label != null ? `${ctx.controlId}-label` : undefined,
"aria-describedby": ctx.hasError ? ctx.messageId : undefined,
// Only injected when the form is disabled so a child's own
// editable/disabled props are never clobbered with undefined.
...(ctx.disabled
? { "aria-disabled": true, editable: false, disabled: true }
: {}),
})
: children;
return (
<FormFieldContext.Provider value={ctx}>
<View ref={ref} className={cn("gap-1.5", className)} {...props}>
{label != null && <FormLabel className={labelClassName}>{label}</FormLabel>}
{control}
{ctx.hasError ? (
<FormMessage>{error}</FormMessage>
) : description != null ? (
<Text className="text-sm text-muted-foreground">{description}</Text>
) : null}
</View>
</FormFieldContext.Provider>
);
}
);
export interface FormLabelProps extends TextProps {
className?: string;
}
const FormLabel = forwardRef<React.ElementRef<typeof Text>, FormLabelProps>(
function FormLabel({ className, children, ...props }, ref) {
const field = useContext(FormFieldContext);
const asterisk = field?.required ? <Text className="text-destructive"> *</Text> : null;
// Opted-in fields light their label; an error always wins (red, static).
if (field && field.valid !== undefined && !field.hasError) {
return (
<LitLabel
ref={ref}
on={field.valid}
delay={field.validDelay ?? 0}
nativeID={`${field.controlId}-label`}
className={className}
{...props}
>
{children}
{asterisk}
</LitLabel>
);
}
return (
<Text
ref={ref}
className={cn(
"text-sm font-medium text-foreground",
field?.hasError && "text-destructive",
className
)}
nativeID={field ? `${field.controlId}-label` : undefined}
{...props}
>
{children}
{asterisk}
</Text>
);
}
);
/** A label that switches on — muted until its field passes, then ink over
* timing.fast. Colour only, so reduced motion keeps it unchanged. */
const LitLabel = forwardRef<React.ElementRef<typeof Text>, FormLabelProps & { on: boolean; delay: number }>(
function LitLabel({ on, delay, className, style, children, ...props }, ref) {
const { colors } = useTheme();
const lit = useSharedValue(0);
const first = React.useRef(true);
React.useEffect(() => {
const target = on ? 1 : 0;
// Only the first switch-on waits its turn in reading order.
lit.value =
first.current && on ? withDelay(delay, withTiming(target, IGNITE)) : withTiming(target, IGNITE);
first.current = false;
}, [on, delay, lit]);
const muted = colors.mutedForeground;
const ink = colors.foreground;
const litStyle = useAnimatedStyle(() => ({ color: interpolateColor(lit.value, [0, 1], [muted, ink]) }));
return (
<Animated.Text
ref={ref as React.Ref<never>}
className={cn("text-sm font-medium", className)}
style={[style, litStyle]}
{...props}
>
{children}
</Animated.Text>
);
}
);
export interface FormMessageProps extends TextProps {
className?: string;
}
const FormMessage = forwardRef<React.ElementRef<typeof Text>, FormMessageProps>(
function FormMessage({ className, children, ...props }, ref) {
const field = useContext(FormFieldContext);
const reduceMotion = useReducedMotion();
// Mount entrance — the message fades and settles down 4px so validation
// feedback arrives instead of teleporting in (error-message recipe).
const t = useSharedValue(0);
React.useEffect(() => {
t.value = reduceMotion ? 1 : withTiming(1, timing.fast);
}, [t, reduceMotion]);
const msgStyle = useAnimatedStyle(() => ({
opacity: t.value,
transform: [{ translateY: reduceMotion ? 0 : (1 - t.value) * -4 }],
}));
if (children == null) return null;
return (
<Animated.View style={msgStyle}>
<Text
ref={ref}
nativeID={field?.messageId}
className={cn("text-sm font-medium text-destructive", className)}
accessibilityLiveRegion="polite"
accessibilityRole="alert"
{...props}
>
{children}
</Text>
</Animated.View>
);
}
);
// --- Settings form primitives ---
export interface FormSectionProps extends ViewProps {
/** Optional header text rendered above the group. */
title?: string;
/** Optional footer text rendered below the group. */
footer?: string;
/** Extra classes for the header text. */
titleClassName?: string;
/** Extra classes for the footer text. */
footerClassName?: string;
className?: string;
}
const FormSection = forwardRef<React.ElementRef<typeof View>, FormSectionProps>(
function FormSection({ title, footer, titleClassName, footerClassName, className, children, ...props }, ref) {
return (
<View ref={ref} className={cn("mb-6", className)} {...props}>
{title != null && (
<Text
className={cn(
"mx-4 mb-2 text-[13px] text-muted-foreground",
titleClassName
)}
accessibilityRole="header"
>
{title}
</Text>
)}
<View className="mx-4 overflow-hidden rounded-2xl border border-border bg-card">
{children}
</View>
{footer != null && (
<Text
className={cn(
"mx-4 mt-2 text-[13px] text-muted-foreground",
footerClassName
)}
>
{footer}
</Text>
)}
</View>
);
}
);
export interface FormRowProps extends Omit<PressableProps, "children"> {
/** Primary label. */
label: string;
/** Secondary description below the label. */
description?: string;
/** Slot rendered before the label (icon, avatar). */
leading?: React.ReactNode;
/** Control rendered on the trailing side (Switch, value text, etc.). */
trailing?: React.ReactNode;
/** Trailing value text ("15 minutes before"). When it changes it swaps in
* place — the new value starts dim and ignites. Renders before `trailing`. */
value?: string;
/** Show a disclosure chevron on the trailing edge. */
showChevron?: boolean;
/** Haptic on press. Default "selection"; false to disable. */
haptic?: "light" | "selection" | false;
className?: string;
labelClassName?: string;
descriptionClassName?: string;
children?: React.ReactNode;
}
const FormRow = forwardRef<React.ElementRef<typeof Pressable>, FormRowProps>(
function FormRow(
{
label,
description,
leading,
trailing,
value,
showChevron = false,
haptic = "selection",
className,
labelClassName,
descriptionClassName,
children,
disabled,
onPress,
onPressIn,
onPressOut,
...props
},
ref
) {
const haptics = useHaptic();
const reduceMotion = useReducedMotion();
const press = usePressAnimation({ pressedScale: 0.98, fast: true });
const ink = useSharedValue(0);
const inkStyle = useAnimatedStyle(() => ({ opacity: ink.value * 0.12 }));
// Disclosure chevron nudges right while the row is held — a hint that
// the row leads somewhere.
const chevX = useSharedValue(0);
const chevStyle = useAnimatedStyle(() => ({
transform: [{ translateX: chevX.value }],
}));
// Disabled crossfades over timing.normal instead of snapping to half.
const dim = useSharedValue(disabled ? ROW_DISABLED : 1);
React.useEffect(() => {
dim.value = withTiming(disabled ? ROW_DISABLED : 1, timing.normal);
}, [disabled, dim]);
const dimStyle = useAnimatedStyle(() => ({ opacity: dim.value }));
// Rows without onPress (a Switch or value row) never highlight — only
// rows that navigate or act flash; a static row stays still.
const interactive = onPress != null && !disabled;
const handlePress = (e: GestureResponderEvent) => {
if (haptic) haptics[haptic === "selection" ? "selection" : "light"]();
onPress?.(e);
};
return (
<Pressable
ref={ref}
disabled={disabled}
onPress={onPress ? handlePress : undefined}
onPressIn={(e) => {
if (!interactive) {
onPressIn?.(e);
return;
}
press.onPressIn();
if (!reduceMotion) {
ink.value = withTiming(1, timing.instant);
chevX.value = withTiming(3, timing.fast);
}
onPressIn?.(e);
}}
onPressOut={(e) => {
if (!interactive) {
onPressOut?.(e);
return;
}
press.onPressOut();
ink.value = withTiming(0, timing.normal);
if (!reduceMotion) chevX.value = withSpring(0, spring.press);
onPressOut?.(e);
}}
className={cn(
"min-h-12 flex-row items-center gap-3 px-4 py-2.5",
className
)}
// Non-pressable rows announce "none" — silence would let the
// platform guess a role the row doesn't have.
accessibilityRole={onPress ? "button" : "none"}
accessibilityLabel={value != null ? `${label}, ${value}` : label}
accessibilityState={{ disabled: !!disabled }}
{...props}
>
<Animated.View
pointerEvents="none"
style={[{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }, inkStyle]}
className="bg-foreground"
/>
<Animated.View style={[press.animatedStyle, dimStyle]} className="flex-1 flex-row items-center gap-3">
{leading}
<View className="flex-1 gap-0.5">
<Text className={cn("text-base leading-tight text-foreground", labelClassName)} numberOfLines={1}>
{label}
</Text>
{description != null && (
<Text className={cn("text-sm text-muted-foreground", descriptionClassName)} numberOfLines={2}>
{description}
</Text>
)}
{children}
</View>
{value != null && <RowValue value={value} />}
{trailing}
{showChevron && (
<Animated.View style={chevStyle}>
<Icon
name="chevron-right"
size="sm"
color="mutedForeground"
accessibilityElementsHidden
importantForAccessibility="no"
/>
</Animated.View>
)}
</Animated.View>
</Pressable>
);
}
);
/** Trailing value that swaps in place: the new value starts dim at 0.96 and
* ignites (opacity over timing.fast, scale on spring.swap). No travel. */
function RowValue({ value }: { value: string }) {
const reduce = useReducedMotion();
const on = useSharedValue(1);
const scale = useSharedValue(1);
const shown = React.useRef(value);
React.useEffect(() => {
if (shown.current === value) return;
shown.current = value;
on.value = SWAP_DIM;
on.value = withTiming(1, IGNITE);
if (reduce) return;
scale.value = 0.96;
scale.value = withSpring(1, spring.swap);
}, [value, reduce, on, scale]);
const style = useAnimatedStyle(() => ({ opacity: on.value, transform: [{ scale: scale.value }] }));
return (
<Animated.View style={style}>
<Text className="text-base text-muted-foreground" numberOfLines={1}>
{value}
</Text>
</Animated.View>
);
}
export interface FormSeparatorProps extends ViewProps {
className?: string;
}
const FormSeparator = forwardRef<React.ElementRef<typeof View>, FormSeparatorProps>(
function FormSeparator({ className, ...props }, ref) {
return (
<View
ref={ref}
className={cn("ml-4 h-px bg-border", className)}
accessibilityElementsHidden
importantForAccessibility="no"
{...props}
/>
);
}
);
export const Form = Object.assign(FormRoot, {
Field: FormField,
Label: FormLabel,
Message: FormMessage,
Section: FormSection,
Row: FormRow,
Separator: FormSeparator,
});
Form.displayName = "Form";
FormField.displayName = "Form.Field";
FormLabel.displayName = "Form.Label";
FormMessage.displayName = "Form.Message";
FormSection.displayName = "Form.Section";
FormRow.displayName = "Form.Row";
FormSeparator.displayName = "Form.Separator";
LitLabel.displayName = "Form.LitLabel"; import { Form } from "@/components/ui/form/form";
export function Example() {
return (
<Form />
);
} Installation
Install the dependencies
npx expo install react-native-reanimatedAdd the components it builds on
Copy the source from the Code tab into your project
components/ui/form/form.tsxcomponents/ui/form/index.tsUpdate the import paths to match your project setup.
Usage
import { useFormContext, useFormField, Form } from "@/components/ui/form/form"; <Form /> Also exported: useFormContext, useFormField.
Examples
"Add a card" in a banking app, green accent.
import * as React from "react";
import { View, type LayoutChangeEvent } from "react-native";
import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withSequence, withSpring, withTiming } from "react-native-reanimated";
import { Accent, Sheen, ease, spring, tempo, timing, useHaptic, useReducedMotion } from "@lumioui/core";
import { Form } from "../form";
import { Button } from "../../button/button";
import { Icon } from "../../icon/icon";
import { Input } from "../../input/input";
import { Text } from "../../text/text";
/** "4242424242424242" → "4242 4242 4242 4242". */
function groupCard(raw: string) {
return raw.replace(/\D/g, "").slice(0, 16).replace(/(\d{4})(?=\d)/g, "$1 ");
}
/** Luhn checksum — catches a mistyped digit the moment the 16th lands. */
function luhn(digits: string) {
let sum = 0;
for (let i = 0; i < digits.length; i++) {
let d = Number(digits[digits.length - 1 - i]);
if (i % 2 === 1) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
}
return sum % 10 === 0;
}
/** "0928" → "09/28". */
function formatExpiry(raw: string) {
const d = raw.replace(/\D/g, "").slice(0, 4);
return d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d;
}
/** Fields switch on in reading order, this far apart. */
const LABEL_STAGGER = 60;
/** Height of the folded card face. */
const FACE_H = 72;
/** The added card holds this long before the header resolves (act → hold). */
const HOLD = 700;
/** Disabled CTA brightness — the fill crossfades up from here when valid. */
const CTA_DIM = 0.5;
/** Refused-press flicker kick in points. */
const FLICK = 4;
type Phase = "edit" | "folding" | "added";
/** Header line that swaps in place — the new copy starts dim at 0.96 and ignites. */
function SwapLine({ text, className, muted }: { text: string; className?: string; muted?: boolean }) {
const reduce = useReducedMotion();
const on = useSharedValue(1);
const scale = useSharedValue(1);
const shown = React.useRef(text);
React.useEffect(() => {
if (shown.current === text) return;
shown.current = text;
on.value = 0.35;
on.value = withTiming(1, { duration: timing.fast.duration, easing: Easing.bezier(...ease.out) });
if (reduce) return;
scale.value = 0.96;
scale.value = withSpring(1, spring.swap);
}, [text, reduce, on, scale]);
const style = useAnimatedStyle(() => ({ opacity: on.value, transform: [{ scale: scale.value }] }));
return (
<Animated.View style={[style, { transformOrigin: "left" }]}>
<Text variant={muted ? "muted" : undefined} className={className} accessibilityLiveRegion="polite">
{text}
</Text>
</Animated.View>
);
}
/** FormCheckout — "Add a card" in a banking app, green accent. Each field's
* label switches on from muted to ink as its value passes (in reading
* order on first view), and the CTA's fill crossfades up when the last one
* does; pressing it early flickers. "Add card" folds the form into the
* card it made: fields dim, the stack folds into one card face, a single
* sheen crosses it, it holds, then the header resolves to "Card added".
* (Finish the number with "242" for a valid test card.) */
export function FormCheckout() {
const haptic = useHaptic();
const reduce = useReducedMotion();
const [number, setNumber] = React.useState("4242 4242 4242 4");
const [expiry, setExpiry] = React.useState("");
const [cvc, setCvc] = React.useState("");
const [holder, setHolder] = React.useState("Tomás Herrera");
const [phase, setPhase] = React.useState<Phase>("edit");
const [sheen, setSheen] = React.useState(0);
const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);
React.useEffect(() => () => timers.current.forEach(clearTimeout), []);
const later = (ms: number, fn: () => void) => timers.current.push(setTimeout(fn, ms));
const digits = number.replace(/\D/g, "");
const numberOk = digits.length === 16 && luhn(digits);
const numberError = digits.length === 16 && !numberOk ? "That number doesn't look right" : undefined;
const [mm] = expiry.split("/");
const expiryError = expiry.length === 5 && (Number(mm) < 1 || Number(mm) > 12) ? "Month must be 01–12" : undefined;
const expiryOk = expiry.length === 5 && !expiryError;
const cvcOk = cvc.length >= 3;
const holderOk = holder.trim().length > 2;
const valid = numberOk && expiryOk && cvcOk && holderOk;
// CTA fill crossfades with validity; a refused press flickers once.
const cta = useSharedValue(valid ? 1 : CTA_DIM);
const kick = useSharedValue(0);
React.useEffect(() => {
cta.value = withTiming(valid ? 1 : CTA_DIM, timing.normal);
}, [valid, cta]);
const ctaStyle = useAnimatedStyle(() => ({ opacity: cta.value, transform: [{ translateX: kick.value }] }));
// The fold: fields dim, the measured stack folds to the face height, the face ignites over it.
const [formH, setFormH] = React.useState(0);
const fields = useSharedValue(1);
const height = useSharedValue(-1);
const face = useSharedValue(0);
const foldStyle = useAnimatedStyle(() => (height.value < 0 ? {} : { height: height.value }));
const fieldsStyle = useAnimatedStyle(() => ({ opacity: fields.value }));
const faceStyle = useAnimatedStyle(() => ({ opacity: face.value }));
const add = () => {
if (phase !== "edit") return;
if (!valid) {
haptic.warning();
if (reduce) {
cta.value = withSequence(withTiming(0.35, timing.instant), withTiming(CTA_DIM, timing.fast));
} else {
kick.value = FLICK;
kick.value = withSpring(0, spring.shake);
}
return;
}
haptic.light();
setPhase("folding");
fields.value = withTiming(0.35, timing.recede);
height.value = formH;
height.value = reduce ? FACE_H : withDelay(timing.recede.duration, withSpring(FACE_H, spring.surface));
face.value = withDelay(timing.recede.duration, withTiming(1, timing.fast));
later(timing.recede.duration + tempo.settle, () => {
haptic.success();
setSheen((n) => n + 1);
later(HOLD, () => setPhase("added"));
});
};
const again = () => {
haptic.light();
face.value = withTiming(0, timing.recede);
height.value = reduce ? formH : withSpring(formH, spring.surface);
fields.value = withDelay(timing.recede.duration, withTiming(1, timing.fast));
setNumber("");
setExpiry("");
setCvc("");
setPhase("edit");
// Back to natural height once the unfold has landed.
later(tempo.drift, () => {
height.value = -1;
});
};
const last4 = digits.slice(-4);
return (
<Accent color="green" className="w-full">
<View className="w-full gap-5 rounded-3xl bg-card p-5">
<View className="gap-1">
<SwapLine text={phase === "added" ? "Card added" : "Add a card"} className="text-2xl font-bold tracking-tight" />
<SwapLine
muted
text={phase === "added" ? "Ready for top-ups and bills on Halden Bank." : "Used for top-ups and bills. Never charged without asking."}
/>
</View>
<Animated.View style={foldStyle} className="overflow-hidden">
<Animated.View
style={fieldsStyle}
pointerEvents={phase === "edit" ? "auto" : "none"}
accessibilityElementsHidden={phase !== "edit"}
importantForAccessibility={phase === "edit" ? "auto" : "no-hide-descendants"}
onLayout={(e: LayoutChangeEvent) => phase === "edit" && setFormH(e.nativeEvent.layout.height)}
>
<Form className="gap-4">
<Form.Field name="number" label="Card number" error={numberError} valid={numberOk} validDelay={0}>
<Input
value={number}
onChangeText={(t) => setNumber(groupCard(t))}
keyboardType="number-pad"
placeholder="1234 5678 9012 3456"
leftIcon={<Icon name="credit-card" size="sm" color="mutedForeground" />}
success={numberOk}
className="tabular-nums"
/>
</Form.Field>
<View className="flex-row gap-3">
<Form.Field name="expiry" label="Expiry" error={expiryError} valid={expiryOk} validDelay={LABEL_STAGGER} className="flex-1">
<Input
value={expiry}
onChangeText={(t) => setExpiry(formatExpiry(t))}
keyboardType="number-pad"
placeholder="MM/YY"
className="tabular-nums"
/>
</Form.Field>
<Form.Field
name="cvc"
label="CVC"
description="3 digits on the back"
valid={cvcOk}
validDelay={LABEL_STAGGER * 2}
className="flex-1"
>
<Input
value={cvc}
onChangeText={(t) => setCvc(t.replace(/\D/g, "").slice(0, 4))}
keyboardType="number-pad"
placeholder="123"
secureTextEntry
/>
</Form.Field>
</View>
<Form.Field name="holder" label="Name on card" valid={holderOk} validDelay={LABEL_STAGGER * 3}>
<Input value={holder} onChangeText={setHolder} autoComplete="name" />
</Form.Field>
</Form>
</Animated.View>
{/* The card the form made — it ignites over the folded stack. */}
{phase !== "edit" ? (
<Animated.View
style={[faceStyle, { height: FACE_H }]}
className="absolute left-0 right-0 top-0 flex-row items-center gap-3 overflow-hidden rounded-2xl bg-primary px-4"
accessibilityLabel={`Card ending ${last4}, expires ${expiry}`}
>
<Icon name="credit-card" size="md" color="primaryForeground" />
<View className="flex-1">
<Text className="text-[17px] font-semibold tabular-nums text-primary-foreground">•••• {last4} · {expiry}</Text>
<Text className="text-sm text-primary-foreground/80" numberOfLines={1}>
{holder}
</Text>
</View>
<Sheen run={sheen} />
</Animated.View>
) : null}
</Animated.View>
<Animated.View style={ctaStyle}>
{phase === "added" ? (
<Button size="lg" variant="secondary" className="w-full" onPress={again}>
Add another card
</Button>
) : (
<Button
size="lg"
className="w-full"
onPress={add}
accessibilityState={{ disabled: !valid, busy: phase === "folding" }}
icon={<Icon name="lock" size="sm" color="primaryForeground" />}
>
Add card
</Button>
)}
</Animated.View>
</View>
</Accent>
);
} A calendar app's settings page built from Form.Section, Form.Row and Form.Separator.
import * as React from "react";
import { View } from "react-native";
import { Form } from "../form";
import { Icon, type IconName } from "../../icon/icon";
import { Switch } from "../../switch/switch";
import { Text } from "../../text/text";
/** Settings-style tile — the row's colour lives in its icon, not the chrome. */
function Tile({ name, className }: { name: IconName; className: string }) {
return (
<View className={`h-8 w-8 items-center justify-center rounded-lg ${className}`}>
<Icon name={name} size="sm" color="#FFFFFF" />
</View>
);
}
const ALERTS = ["None", "At time of event", "5 minutes before", "15 minutes before", "1 hour before"];
const ZONES = ["Central European", "Eastern", "Gulf", "Pacific"];
/** FormPreferences — a calendar app's settings page built from Form.Section,
* Form.Row and Form.Separator. Switch rows stay still when tapped (only
* navigating rows highlight); "Default alert" and "Time Zone" cycle their
* value on tap and the new value swaps in place (dim / ignite), and
* turning off Time Zone Override crossfades the zone row to disabled. */
export function FormPreferences() {
const [override, setOverride] = React.useState(true);
const [declined, setDeclined] = React.useState(false);
const [birthdays, setBirthdays] = React.useState(true);
const [alert, setAlert] = React.useState(3);
const [zone, setZone] = React.useState(0);
return (
<View className="-mx-4">
<Text className="mx-4 mb-5 text-3xl font-bold tracking-tight">Calendar</Text>
<Form.Section title="Events" footer="Applies to new events on every calendar.">
<Form.Row
label="Default alert"
leading={<Tile name="bell" className="bg-tint-red" />}
value={ALERTS[alert]}
showChevron
onPress={() => setAlert((a) => (a + 1) % ALERTS.length)}
/>
<Form.Separator className="ml-[60px]" />
<Form.Row
label="Show declined events"
leading={<Tile name="x" className="bg-zinc-500" />}
trailing={<Switch checked={declined} onCheckedChange={setDeclined} />}
/>
<Form.Separator className="ml-[60px]" />
<Form.Row
label="Birthdays"
description="From your contacts"
leading={<Tile name="star" className="bg-tint-amber" />}
trailing={<Switch checked={birthdays} onCheckedChange={setBirthdays} />}
/>
</Form.Section>
<Form.Section title="Time zone" footer={override ? `Event times show in ${ZONES[zone]} time, wherever you are.` : "Event times follow your current location."}>
<Form.Row
label="Time Zone Override"
leading={<Tile name="globe" className="bg-tint-teal" />}
trailing={<Switch checked={override} onCheckedChange={setOverride} />}
/>
<Form.Separator className="ml-[60px]" />
<Form.Row
label="Time Zone"
leading={<Tile name="clock" className="bg-tint-slate" />}
value={ZONES[zone]}
showChevron
disabled={!override}
onPress={() => setZone((z) => (z + 1) % ZONES.length)}
/>
</Form.Section>
</View>
);
} API reference
Form
disabled boolean Disable all fields in the form (exposed via useFormContext).
—className string — FormField
name string Field name (for identification/analytics).
—label string Shorthand label — equivalent to rendering <Form.Label>.
—required boolean Show a required asterisk on the label.
—error string Error message — renders <Form.Message> and marks the field invalid.
—description string Hint text shown when there is no error.
—valid boolean Opt into the valid light: the label stays muted while false and switches on to ink when true. Omit to keep the label always ink.
—validDelay number Delay before the label's first switch-on, so pre-filled fields light in reading order (e.g. index × 60). Later changes are immediate.
—className string — labelClassName string — FormLabel
className string — FormMessage
className string — FormSection
title string Optional header text rendered above the group.
—footer string Optional footer text rendered below the group.
—titleClassName string Extra classes for the header text.
—footerClassName string Extra classes for the footer text.
—className string — FormRow
label* string Primary label.
—description string Secondary description below the label.
—leading React.ReactNode Slot rendered before the label (icon, avatar).
—trailing React.ReactNode Control rendered on the trailing side (Switch, value text, etc.).
—value string Trailing value text ("15 minutes before"). When it changes it swaps in place — the new value starts dim and ignites. Renders before trailing.
showChevron boolean Show a disclosure chevron on the trailing edge.
—haptic "light" | "selection" | false Haptic on press. Default "selection"; false to disable.
—className string — labelClassName string — descriptionClassName string — children React.ReactNode — FormSeparator
className string —