Input

Displays a form input field for a single line of text.

Encrypted at rest. Rotate it any time.
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"
import { color, leading, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  field: { width: "100%", maxWidth: "20rem" },
  hint: { color: color.mutedForeground, fontSize: text.sm, lineHeight: leading.sm },
})

export default function InputDemo() {
  return (
    <VStack gap={2} xstyle={styles.field}>
      <Label htmlFor="input-demo-key">API key</Label>
      <Input id="input-demo-key" type="password" placeholder="sk-..." />
      <span {...stylex.props(styles.hint)}>Encrypted at rest. Rotate it any time.</span>
    </VStack>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts from the same page. The input's field surface, its focus ring and its invalid ring come from it.

Paste field-state.ts and state-styles.ts from the same installation page.

Copy and paste the following code into your project.

components/ui/input.tsx
"use client"

import { createContext, use } from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import type { CSSProperties } from "react"
import { stateStyles } from "@/lib/state-styles"

import { breakpoints } from "@/theme/breakpoints.stylex"
import { FieldStateContext } from "@/lib/field-state"
import { states } from "@/theme/states"
import { color, duration, ease, leading, radius, space, text } from "@/theme/tokens.stylex"

const InputGroupContext = createContext<{ disabled: boolean; invalid: boolean } | null>(null)

const styles = stylex.create({
  base: {
    width: "100%",
    minWidth: 0,
    height: space.s8,
    borderRadius: radius.lg,
    borderWidth: 1,
    borderStyle: "solid",
    paddingInline: space.s2_5,
    paddingBlock: space.s1,
    fontSize: { default: text.base, [breakpoints.md]: text.sm },
    lineHeight: { default: leading.base, [breakpoints.md]: leading.sm },
    color: color.foreground,
    transitionProperty: "color, background-color, border-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    opacity: { default: 1, ":disabled": 0.5 },
    pointerEvents: { default: null, ":disabled": "none" },
    "::placeholder": { color: color.mutedForeground },
    "::file-selector-button": {
      height: space.s6,
      marginInlineEnd: space.s2,
      borderWidth: 0,
      backgroundColor: color.transparent,
      color: color.foreground,
      fontSize: text.sm,
      fontWeight: 500,
    },
  },
  inGroup: {
    flexGrow: 1,
    flexBasis: 0,
    borderWidth: 0,
    borderRadius: 0,
    backgroundColor: { default: color.transparent, ":disabled": color.transparent },
    boxShadow: { default: "none", ":focus-visible": "none" },
    opacity: { default: 1, ":disabled": 1 },
  },
})

const inputStyles = {
  base: [styles.base, states.focusRing, states.fieldSurface],
  invalid: states.invalidRing,
  inGroup: styles.inGroup,
}

type InputProps = Omit<InputPrimitive.Props, "className" | "style"> & {
  style?: CSSProperties
  xstyle?: StyleXStyles
  className?: string
}

function Input({
  disabled,
  "aria-invalid": ariaInvalid,
  xstyle,
  className,
  style,
  ...props
}: InputProps) {
  const fieldState = use(FieldStateContext)
  const group = use(InputGroupContext)
  const invalid = group?.invalid || (ariaInvalid != null && ariaInvalid !== false)

  return (
    <InputPrimitive
      data-slot={group ? "input-group-control" : "input"}
      {...props}
      disabled={group?.disabled || disabled}
      aria-invalid={invalid || undefined}
      {...stateStyles(
        (state) => [
          styles.base,
          states.focusRing,
          states.fieldSurface,
          (state.valid === false || fieldState?.valid === false || invalid) &&
            !group &&
            states.invalidRing,
          state.disabled && !group && states.disabled,
          group && styles.inGroup,
          xstyle,
        ],
        { className, style },
      )}
    />
  )
}

export { Input, InputGroupContext, inputStyles }

Update the import paths to match your project setup.

Usage

import { Input } from "@/components/ui/input"
<Input type="email" placeholder="me@example.com" />

Basic

import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"

const styles = stylex.create({
  field: { width: "100%", maxWidth: "20rem" },
})

export default function InputBasic() {
  return <Input placeholder="Enter text" xstyle={styles.field} />
}

Label

Pair the input with a Label and match htmlFor to the input's id. A Field adds the description and error text around it.

import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"

const styles = stylex.create({
  field: { width: "100%", maxWidth: "20rem" },
})

export default function LabelDemo() {
  return (
    <VStack gap={2} xstyle={styles.field}>
      <Label htmlFor="label-demo-email">Email</Label>
      <Input id="label-demo-email" type="email" placeholder="me@example.com" />
    </VStack>
  )
}

Disabled

Set disabled on Field to disable its control and dim its label, or on a bare control directly.

import { Field, FieldLabel } from "@/components/ui/field"
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"

const styles = stylex.create({
  field: { width: "100%", maxWidth: "20rem" },
})

function BareDisabled() {
  return (
    <VStack gap={2} xstyle={styles.field}>
      <Label htmlFor="input-disabled-email" disabled>
        Email
      </Label>
      <Input id="input-disabled-email" type="email" placeholder="me@example.com" disabled />
    </VStack>
  )
}

export default function DisabledExample() {
  return (
    <VStack gap={5}>
      <BareDisabled />
      <Field disabled>
        <FieldLabel>Email</FieldLabel>
        <Input type="email" placeholder="me@example.com" />
      </Field>
    </VStack>
  )
}

Invalid

Set invalid on Field to style its control, or aria-invalid on a bare control. The examples show both.

import { Input } from "@/components/ui/input"
import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"

export default function InputInvalid() {
  return (
    <VStack gap={5} width="100%" maxWidth="24rem">
      <VStack gap={2}>
        <Label htmlFor="input-invalid-bare">Email</Label>
        <Input
          id="input-invalid-bare"
          defaultValue="not-an-email"
          aria-invalid
          aria-describedby="input-invalid-bare-error"
        />
        <FieldError id="input-invalid-bare-error">Enter a valid email address.</FieldError>
      </VStack>
      <Field invalid>
        <FieldLabel>Email</FieldLabel>
        <Input defaultValue="not-an-email" />
        <FieldError>Enter a valid email address.</FieldError>
      </Field>
    </VStack>
  )
}

File

Use type="file" for a file input. The browser's own button is restyled to the control's type scale so the height does not change.

import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"

const styles = stylex.create({
  field: { width: "100%", maxWidth: "20rem" },
})

export default function InputFile() {
  return (
    <VStack gap={2} xstyle={styles.field}>
      <Label htmlFor="input-file-avatar">Avatar</Label>
      <Input id="input-file-avatar" type="file" accept="image/*" />
    </VStack>
  )
}

Required

Use the required attribute for required inputs. Mark the label too, since required alone is only announced once the control has focus.

import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"
import { color } from "@/theme/tokens.stylex"

const styles = stylex.create({
  field: { width: "100%", maxWidth: "20rem" },
  marker: { color: color.destructive },
})

export default function InputRequired() {
  return (
    <VStack gap={2} xstyle={styles.field}>
      <Label htmlFor="input-required-name">
        Workspace name
        <span {...stylex.props(styles.marker)} aria-hidden>
          *
        </span>
      </Label>
      <Input id="input-required-name" placeholder="Acme" required />
    </VStack>
  )
}

Inline

Put the input and a button in an HStack for a one-line form.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { HStack } from "@/components/ui/stack"

const styles = stylex.create({
  row: { width: "100%", maxWidth: "20rem" },
})

export default function InputInline() {
  return (
    <HStack gap={2} xstyle={styles.row}>
      <Input type="email" placeholder="me@example.com" aria-label="Email" />
      <Button variant="outline">Subscribe</Button>
    </HStack>
  )
}

Grid

Lay several inputs out in a grid to place them side by side.

import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"
import { space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  grid: {
    display: "grid",
    gridTemplateColumns: "1fr 1fr",
    gap: space.s4,
    width: "100%",
    maxWidth: "26rem",
  },
})

export default function InputGrid() {
  return (
    <div {...stylex.props(styles.grid)}>
      <VStack gap={2}>
        <Label htmlFor="input-grid-first">First name</Label>
        <Input id="input-grid-first" placeholder="Ada" />
      </VStack>
      <VStack gap={2}>
        <Label htmlFor="input-grid-last">Last name</Label>
        <Input id="input-grid-last" placeholder="Lovelace" />
      </VStack>
    </div>
  )
}

Input group

To put an icon, a prefix or a button inside the field, wrap it in InputGroup. The input detects the group and drops its own border, fill and ring so the group can draw them once.

⌘K
import * as stylex from "@stylexjs/stylex"
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
import { Input } from "@/components/ui/input"
import { Search } from "lucide-react"

const styles = stylex.create({
  group: { maxWidth: "20rem" },
})

export default function InputInputGroup() {
  return (
    <InputGroup xstyle={styles.group}>
      <Input placeholder="Search docs" aria-label="Search docs" />
      <InputGroupAddon>
        <Search />
      </InputGroupAddon>
      <InputGroupAddon align="inline-end">
        <InputGroupText>⌘K</InputGroupText>
      </InputGroupAddon>
    </InputGroup>
  )
}

Button group

To attach a button to the outside of the field, use ButtonGroup. Square the input's trailing corners yourself. The group's joining rules apply to Button, which reads the group through context, and Input does not.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { Input } from "@/components/ui/input"
import { Copy } from "lucide-react"

const styles = stylex.create({
  group: { width: "100%", maxWidth: "20rem" },
  input: {
    flexGrow: 1,
    minWidth: 0,
    borderStartEndRadius: 0,
    borderEndEndRadius: 0,
    borderInlineEndWidth: 0,
  },
})

export default function InputButtonGroup() {
  return (
    <ButtonGroup xstyle={styles.group} aria-label="Invite link">
      <Input
        readOnly
        defaultValue="https://carbon.style/invite/8fa2"
        aria-label="Invite link"
        xstyle={styles.input}
      />
      <Button variant="outline" size="icon" aria-label="Copy link">
        <Copy />
      </Button>
    </ButtonGroup>
  )
}

Below the md breakpoint the font size is 16px rather than 14px. iOS Safari zooms the page when a focused control's text is any smaller, and the zoom does not reverse on blur.

API reference

Pass render to replace a part's element or compose it with another component.

Input wraps the Base UI Input part and accepts its props, including defaultValue, value and onValueChange. See the Base UI reference.

Input

PropTypeDefault
typeevery <input> typetext
disabledbooleanfalse
aria-invalidboolean—
onValueChange(value: string, details) => void—
renderReactElement | (props, state) => Element—
xstyleStyleXStyles—
classNamestring—

Inside an InputGroup the group's disabled and invalid win. An input can be disabled on its own, but it cannot opt out of a disabled group.

inputStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
baseThe control: frame, fill, type scale, placeholder, disabled
invalidThe destructive border and ring
inGroupThe frameless form an InputGroup member takes

base is three styles, in merge order: the control's own, the shared states.focusRing, and states.fieldSurface, which is the hairline edge, the fill and the drop. invalid is states.invalidRing from theme/states.ts. Set a border of your own through --carbon-border-color rather than borderColor. A flat borderColor replaces the property with all of its conditions, so the ring loses its focus colour.

Data attributes

AttributeOnEffect
data-slot="input"a standalone inputTargets the control from a parent
data-slot="input-group-control"an input in a groupWhat InputGroup watches to draw its ring
data-disabled, data-valid, data-dirtythe controlBase UI writes them inside a Field