Input

Text field with label, description and error message.

The Input component, running in the example app.

A text field with an optional label, description and error message.

Its focus border crosses between colours on the UI thread rather than snapping, and it comes in two backgrounds and three heights.

Passing errorMessage puts the field in its invalid state and replaces the description — so the two never appear together and the error is not competing with help text.

For several lines, use Textarea; for a field with something attached to either end, use InputGroup.

Installation

Input ships with the library — no separate install.

import { Input, Card, Button, PencilIcon, SearchIcon, Chip } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add input

Usage

<Input label="Email" placeholder="you@example.com" />

<Input variant="filled" size="lg" label="Workspace" isRequired />

<Input
  label="Description"
  placeholder="A short description"
  description="Shown on your public profile."
/>

<Input
  label="Email"
  value={email}
  onChangeText={setEmail}
  errorMessage={error}
/>

// Lifts clear of the software keyboard when it would be covered.
<Input avoidKeyboard label="Comment" placeholder="Say something…" />

Examples

Label, description and error

Setting errorMessage also puts the field into its invalid state — you do not need a separate flag. The description is replaced by the error while one is present.

<Input label="Name" placeholder="Khalid Abdi" />

<Input
  label="Description"
  placeholder="A short description"
  description="Shown on your public profile."
/>

<Input
  label="Email"
  value={email}
  onChangeText={setEmail}
  errorMessage={emailError}
/>

An icon inside the field

startContent and endContent sit inside the field box and are measured, so the text is padded clear of them however wide they turn out to be. They are positioned against the field rather than against the component, which is what keeps them put when a label is added above it.

<Input
  label="Description"
  description="Shown on your public profile."
  placeholder="A short description"
  startContent={<PencilIcon size={18} />}
/>

<Input
  label="Search"
  placeholder="Find a project"
  startContent={<SearchIcon size={18} />}
  endContent={<Chip size="sm">⌘K</Chip>}
/>

Choosing a background

Use outline on the page, where the field draws its own edge. Use filled inside a card or a sheet, where a second border beside the container’s own reads as a seam.

<Input label="Email" placeholder="you@example.com" />

<Card>
  <Card.Content className="gap-4 p-4">
    <Input variant="filled" label="Workspace" placeholder="Acme" />
    <Input variant="filled" label="Notes" multiline />
  </Card.Content>
</Card>

Required fields and multiline

isRequired marks the label with an asterisk and sets the accessibility state with it, so the two cannot drift apart. multiline drops the fixed height for a minimum one and starts the text at the top.

<Input label="Full name" isRequired placeholder="Khalid Abdi" />

<Input
  label="Message"
  multiline
  numberOfLines={4}
  placeholder="Anything we should know?"
/>

Avoiding the keyboard

lift (the default) moves the field by exactly the amount the keyboard covers it, and not at all when it is already clear. The overlap is re-read every frame while the field is focused, so the field keeps its place in the page as it scrolls under and back out of the keyboard.

dock is for a field that is already pinned near the bottom edge — a composer or a search bar. It travels with the keyboard instead of measuring anything; pass the inset it already sits above as keyboardBottomInset.

<Input
  avoidKeyboard
  keyboardOffset={24}
  label="Comment"
  placeholder="Say something…"
/>

// A composer pinned above the home indicator, riding the keyboard.
<Input
  avoidKeyboard
  keyboardMode="dock"
  keyboardBottomInset={insets.bottom}
  placeholder="Message"
  containerClassName="absolute bottom-8 left-5 right-5"
/>

Passing through TextInput props

Anything TextInput accepts works here.

<Input
  label="Password"
  secureTextEntry
  autoCapitalize="none"
  autoComplete="current-password"
  returnKeyType="go"
  onSubmitEditing={signIn}
/>

<Input
  label="Amount"
  keyboardType="decimal-pad"
  inputMode="decimal"
/>

Styling the field and the container separately

className goes on the text field; containerClassName goes on the wrapper that holds the label, field and message.

<Input
  containerClassName="flex-1"
  className="h-12 rounded-full px-4"
  placeholder="Search components"
/>

In a form

Nothing special is needed to put fields in a card — label and isRequired carry the whole field, so the form is the fields and the button under them.

Input — In a form.
<Card className="w-full">
  <Card.Header>
    <Card.Title>Sign in</Card.Title>
    <Card.Description>Welcome back.</Card.Description>
  </Card.Header>
  <Card.Content className="gap-4">
    <Input label="Email" placeholder="you@example.com" isRequired />
    <Input label="Password" secureTextEntry placeholder="••••••••" isRequired />
  </Card.Content>
  <Card.Footer>
    <Button fullWidth>Continue</Button>
  </Card.Footer>
</Card>

Versions

Lifting in a scroll view

A field partway down a long form. Focus it and it lifts by exactly its overlap with the keyboard; scroll and it holds its place between the fields around it, because the overlap is re-read every frame rather than captured once at focus.

<ScrollView keyboardShouldPersistTaps="handled">
  <Input label="From" placeholder="you@example.com" />
  <Input label="Subject" placeholder="An ordinary field" />
  <Input
    avoidKeyboard
    label="Comment"
    placeholder="Say something…"
    multiline
  />
  <Input label="Signature" placeholder="Sent from my phone" />
</ScrollView>

Docked composer

A bar pinned above the home indicator that rides the keyboard up and back down. Nothing is measured — dock travels by the keyboard height less the inset the bar already sits above.

const insets = useSafeAreaInsets();

<KeyboardAvoider
  mode="dock"
  bottomInset={insets.bottom}
  className="absolute left-0 right-0 px-5"
  style={{ bottom: insets.bottom + 16 }}
>
  <View className="flex-row items-center gap-2 rounded-full border border-border bg-surface px-4">
    <Input
      placeholder="Message"
      className="flex-1 border-0 bg-transparent px-0"
      containerClassName="flex-1"
    />
    <SendIcon size={18} />
  </View>
</KeyboardAvoider>

Variants

variant

  • outline (default)
  • filled
Input — variant filled.
filled
{/* On the page, where the field draws its own edge. */}
<Input variant="outline" label="Email" />

{/* Inside a card or a sheet, where a second border reads as a seam. */}
<Input variant="filled" label="Email" />

size

  • sm
  • md (default)
  • lg
Input — size.
<Input size="sm" placeholder="40 tall" />
<Input size="md" placeholder="48 tall" />
<Input size="lg" placeholder="56 tall" />

API Reference

Input

PropTypeDefaultDescription
classNamestring
containerClassNamestring
labelstring
descriptionstring
errorMessagestringError message. When set, the field renders in its invalid state.
isRequiredbooleanMarks the field required — an asterisk on the label, and the a11y state.
disabledboolean
startContentReactNodeContent inside the field, before the text — usually an icon. Measured, so the text is padded clear of it however wide it is. start, not left: it follows the reading direction, and swaps sides under Direction dir="rtl" along with the text it introduces.
endContentReactNodeContent inside the field, after the text — an icon, a unit, a count.
interactiveContentbooleantrueWhether touches reach the content. On by default, so a button placed in the field — a clear ✕, a show-password eye, a unit picker — is pressable without anything else being passed. Only the content itself takes those touches: the padding around it is transparent, so a tap that misses the button still lands on the field and puts the caret in it. Turn it off for pure decoration, where the icon should not be a target at all and every pixel of the field should focus it. That also drops the content from the accessibility tree, which is right for an icon that only restates the label.
avoidKeyboardbooleanfalseKeep the field clear of the software keyboard. Moves by exactly the overlap, and not at all when the field is already clear — or when the keyboard belongs to a different field. The overlap is re-read every frame while the field is focused, so the field keeps its place in the page as it scrolls under and back out of the keyboard. Install react-native-keyboard-controller for this to behave on Android. Do not toggle this at runtime — it changes which component renders the container, which would remount the field and drop focus.
keyboardModeKeyboardAvoidanceMode'lift'How the field gets clear. lift moves it up by its overlap and follows the scroll — right for a field in the flow of a page. dock makes it travel with the keyboard, for a composer already pinned near the bottom edge; pair it with keyboardBottomInset.
keyboardOffsetnumber16Gap kept between the field and the keyboard. keyboardMode="lift" only.
keyboardBottomInsetnumber0How far above the bottom edge the field already sits — usually the safe area inset. keyboardMode="dock" only.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Accepts every TextInputProps. The placeholder colour is resolved from the theme, so it follows light and dark automatically.

The focus border is animated with a shared value rather than switched with a class, because a class can only be swapped wholesale — which is the snap the animation exists to avoid. An invalid field is tinted at rest as well as on focus: the error is a fact about the value, not about whether the field happens to be focused.

Content inside the field

startContent and endContent are measured and turned into padding on the text, so a value never runs underneath them. Removing either one removes its measured padding in the same render, so a conditional clear button or spinner cannot leave an empty inset behind. They are positioned against the field box — not against the component — which is the whole reason they are props rather than something you compose around the outside: a label sits above the field and a description below it, so anything centred on the component as a whole drifts upward the moment a label is added.

They are start and end rather than left and right because they follow the reading direction, and swap sides under Direction dir="rtl" along with the text they introduce.

Content is pressable by default, so a button placed in the field — a clear ✕, a show-password eye, a unit picker — works without anything else being passed. Only the content itself takes those touches: the padding around it stays transparent, so a tap that misses the button still lands on the field and puts the caret in it.

Pass interactiveContent={false} for pure decoration. The icon stops being a target at all, every pixel of the field focuses it, and the content leaves the accessibility tree — which is what an icon that only restates the label should do.

The prop covers both ends at once, since it is one field. When one end is decoration and the other is a control, leave it on and wrap the decorative one in a <View pointerEvents="none">.

InputGroup is still the right answer for a decorator that is not part of the field: a button attached to its end, a select bolted to its start, an addon with a background of its own. It measures the same way; it just spans a different box.

Avoiding the keyboard

avoidKeyboard measures the field and translates it by exactly the overlap with the keyboard — and not at all when the field is already above it, or when the keyboard was opened by a different field. Only the field being typed into moves; without that rule every avoiding field on the screen would lift at once and land stacked on top of the others. That is the difference from KeyboardAvoidingView, which shifts an entire subtree by the full keyboard height wherever the field happens to sit.

The overlap is re-measured every frame for as long as the field is focused and the keyboard is up, which is what lets a field inside a ScrollView hold its place: scroll it clear of the keyboard and the lift decays to nothing, scroll it back under and the lift returns. Measuring once at the moment of focus is right for exactly one frame — after that the field is holding an offset that belongs to where it used to be.

keyboardMode="dock" is the other shape of the problem: a composer or a search bar that is already pinned near the bottom edge and should ride the keyboard rather than get out of its way. It measures nothing; it travels by the keyboard height less keyboardBottomInset, the inset it is already sitting above.

Install react-native-keyboard-controller for this to behave on Android:

npx expo install react-native-keyboard-controller

It is an optional peer, and PanelUIProvider mounts its KeyboardProvider for you when it is present. Without it the hook falls back to Reanimated's useAnimatedKeyboard, which is deprecated in Reanimated 4 and — merely by being called — switches Android out of adjustResize for the whole app.

Use keyboardOffset to change the gap it keeps (16px by default). Do not toggle avoidKeyboard at runtime: it changes which component renders the container, which would remount the field and drop focus.

For anything that is not an Input — a toolbar, a composer, a whole card — use the KeyboardAvoider primitive or the useKeyboardAvoidance hook directly.

Public exports

Values: Input

Types: InputProps

On this page