Input
Text input with label, hint text, error states, and leading icon support.
States
Labels, hints, icons, and the disabled state.
function InputStates() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<Input label="Email" placeholder="you@example.com" leadingIcon={RiMailLine} />
<Input label="Workspace" placeholder="acme" hint="Lowercase letters and dashes only." />
<Input label="Search" placeholder="Search components" leadingIcon={RiSearchLine} />
<Input label="Disabled" placeholder="Unavailable" isDisabled />
</div>
);
}function InputStates() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<Input label="Email" placeholder="you@example.com" leadingIcon={RiMailLine} />
<Input label="Workspace" placeholder="acme" hint="Lowercase letters and dashes only." />
<Input label="Search" placeholder="Search components" leadingIcon={RiSearchLine} />
<Input label="Disabled" placeholder="Unavailable" isDisabled />
</div>
);
}Invalid
Error styling with a corrective hint.
function InputInvalid() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<Input
label="Email"
defaultValue="not-an-email"
isInvalid
hint="Enter a valid email address."
/>
</div>
);
}function InputInvalid() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<Input
label="Email"
defaultValue="not-an-email"
isInvalid
hint="Enter a valid email address."
/>
</div>
);
}Installation
npx shadcn@latest add https://boardcn.dev/r/input.jsonnpx shadcn@latest add https://boardcn.dev/r/input.jsonnpm packages
- @remixicon/react
- react-aria-components
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The 3 files the CLI copies into your project.
"use client";
import {
createContext,
useContext,
type ComponentType,
type ReactNode,
type Ref,
} from "react";
import {
Group as AriaGroup,
Input as AriaInput,
TextField as AriaTextField,
} from "react-aria-components";
import type {
InputProps as AriaInputProps,
TextFieldProps as AriaTextFieldProps,
} from "react-aria-components";
import { Label } from "./label";
import { HintText } from "./hint-text";
import { cx, sortCx } from "@/utils/cx";
type InputSize = "medium" | "small";
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
/* -------------------------------------------------------------------------- */
/* TextFieldContext */
/* -------------------------------------------------------------------------- */
interface TextFieldContextValue {
size?: InputSize;
fieldClassName?: string;
inputClassName?: string;
}
const TextFieldContext = createContext<TextFieldContextValue>({});
/* -------------------------------------------------------------------------- */
/* TextField */
/* -------------------------------------------------------------------------- */
export interface TextFieldProps
extends Omit<AriaTextFieldProps, "className">,
TextFieldContextValue {
className?: string;
children?: ReactNode | ((state: { isRequired: boolean; isInvalid: boolean; isDisabled: boolean; isReadOnly: boolean }) => ReactNode);
}
export function TextField({
size = "medium",
fieldClassName,
inputClassName,
className,
children,
...props
}: TextFieldProps) {
return (
<TextFieldContext.Provider value={{ size, fieldClassName, inputClassName }}>
<AriaTextField
{...props}
data-input-size={size}
className={cx(
"group flex h-max w-full flex-col items-start gap-1",
className,
)}
>
{children as never /* RAC accepts render-prop children */}
</AriaTextField>
</TextFieldContext.Provider>
);
}
TextField.displayName = "TextField";
/* -------------------------------------------------------------------------- */
/* InputBase */
/* -------------------------------------------------------------------------- */
export interface InputBaseProps extends Omit<AriaInputProps, "size" | "className"> {
size?: InputSize;
className?: string;
leadingIcon?: IconComponent;
trailingIcon?: IconComponent;
/** Custom element rendered in the leading slot (Phone basic uses this). */
leadingAddon?: ReactNode;
/** Class for the field shell. */
fieldClassName?: string;
/** Ref to the <input> element. */
ref?: Ref<HTMLInputElement>;
/** Ref to the field shell wrapper. */
groupRef?: Ref<HTMLDivElement>;
}
const inputStyles = sortCx({
field: [
"relative flex w-full items-center",
"rounded-2lg",
"bg-background-tertiary-default text-foreground-icon-tertiary",
"ring-2 ring-inset ring-transparent",
"transition-[background-color,box-shadow,color] duration-[var(--input-transition-ms)] ease",
].join(" "),
fieldSize: {
medium: "p-2", // 8px all sides → h auto = 36
small: "h-8 px-1.5 py-2", // 32 / 6 / 8
},
// When a leadingAddon is present (Phone basic): tighten left padding.
fieldWithAddonSize: {
medium: "h-9 pl-1 pr-2 py-2", // 36 / 4 / 8 / 8
small: "h-8 pl-1 pr-1.5 py-2",
},
content: "flex w-full items-center gap-2 min-w-0",
leftSection: "flex flex-1 items-center gap-0.5 min-w-0",
input: [
"min-w-0 flex-1 bg-transparent border-0 outline-none p-0 m-0",
"font-sans text-body-regular text-text-primary pl-1",
"placeholder:text-text-tertiary",
"focus:placeholder:text-text-primary",
"disabled:text-input-disabled-text disabled:placeholder:text-input-disabled-text",
"disabled:cursor-not-allowed",
"aria-invalid:placeholder:text-text-error-placeholder",
].join(" "),
icon: "size-5 shrink-0",
});
export function InputBase({
size: sizeProp,
leadingIcon: Leading,
trailingIcon: Trailing,
leadingAddon,
fieldClassName,
className,
ref,
groupRef,
...inputProps
}: InputBaseProps) {
const ctx = useContext(TextFieldContext);
const size: InputSize = sizeProp ?? ctx.size ?? "medium";
const hasAddon = leadingAddon !== undefined && leadingAddon !== null;
return (
<AriaGroup
ref={groupRef}
className={({ isFocusWithin, isHovered, isDisabled, isInvalid }) =>
cx(
inputStyles.field,
hasAddon
? inputStyles.fieldWithAddonSize[size]
: inputStyles.fieldSize[size],
// Hover: idle, no focus, no disabled, no invalid
isHovered &&
!isFocusWithin &&
!isDisabled &&
!isInvalid &&
"ring-border-button-hover",
// Focus wins over hover
isFocusWithin &&
!isDisabled &&
!isInvalid &&
"ring-border-button-active",
// Disabled
isDisabled &&
"bg-input-disabled-background text-input-disabled-foreground",
// Invalid
isInvalid && "bg-background-tertiary-error text-foreground-icon-error",
ctx.fieldClassName,
fieldClassName,
)
}
>
<div className={inputStyles.content}>
<div className={inputStyles.leftSection}>
{hasAddon ? (
leadingAddon
) : Leading ? (
<Leading className={inputStyles.icon} aria-hidden />
) : null}
<AriaInput
ref={ref}
{...inputProps}
className={cx(inputStyles.input, ctx.inputClassName, className)}
/>
</div>
{Trailing ? (
<Trailing className={inputStyles.icon} aria-hidden />
) : null}
</div>
</AriaGroup>
);
}
InputBase.displayName = "InputBase";
/* -------------------------------------------------------------------------- */
/* Input (composed) */
/* -------------------------------------------------------------------------- */
export interface InputProps
extends Omit<TextFieldProps, "children">,
Pick<
InputBaseProps,
| "leadingIcon"
| "trailingIcon"
| "leadingAddon"
| "fieldClassName"
| "groupRef"
| "ref"
> {
label?: ReactNode;
hint?: ReactNode;
/** Show an info icon next to the label. Replace with tooltip when Tooltip lands. */
tooltip?: boolean | string;
placeholder?: string;
}
export function Input({
label,
hint,
tooltip,
placeholder,
leadingIcon,
trailingIcon,
leadingAddon,
fieldClassName,
ref,
groupRef,
className,
...textFieldProps
}: InputProps) {
return (
<TextField
{...textFieldProps}
className={className}
// Don't clobber an explicit aria-label; fall back to the placeholder
// only for unlabelled fields that don't provide one.
aria-label={
textFieldProps["aria-label"] ??
(!label && typeof placeholder === "string" ? placeholder : undefined)
}
>
{({ isRequired, isInvalid }) => (
<>
{label && (
<Label
isRequired={isRequired}
isInvalid={isInvalid}
tooltip={tooltip}
>
{label}
</Label>
)}
<InputBase
ref={ref}
groupRef={groupRef}
placeholder={placeholder}
leadingIcon={leadingIcon}
trailingIcon={trailingIcon}
leadingAddon={leadingAddon}
fieldClassName={fieldClassName}
/>
{hint && <HintText isInvalid={isInvalid}>{hint}</HintText>}
</>
)}
</TextField>
);
}
Input.displayName = "Input";"use client";
import {
createContext,
useContext,
type ComponentType,
type ReactNode,
type Ref,
} from "react";
import {
Group as AriaGroup,
Input as AriaInput,
TextField as AriaTextField,
} from "react-aria-components";
import type {
InputProps as AriaInputProps,
TextFieldProps as AriaTextFieldProps,
} from "react-aria-components";
import { Label } from "./label";
import { HintText } from "./hint-text";
import { cx, sortCx } from "@/utils/cx";
type InputSize = "medium" | "small";
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
/* -------------------------------------------------------------------------- */
/* TextFieldContext */
/* -------------------------------------------------------------------------- */
interface TextFieldContextValue {
size?: InputSize;
fieldClassName?: string;
inputClassName?: string;
}
const TextFieldContext = createContext<TextFieldContextValue>({});
/* -------------------------------------------------------------------------- */
/* TextField */
/* -------------------------------------------------------------------------- */
export interface TextFieldProps
extends Omit<AriaTextFieldProps, "className">,
TextFieldContextValue {
className?: string;
children?: ReactNode | ((state: { isRequired: boolean; isInvalid: boolean; isDisabled: boolean; isReadOnly: boolean }) => ReactNode);
}
export function TextField({
size = "medium",
fieldClassName,
inputClassName,
className,
children,
...props
}: TextFieldProps) {
return (
<TextFieldContext.Provider value={{ size, fieldClassName, inputClassName }}>
<AriaTextField
{...props}
data-input-size={size}
className={cx(
"group flex h-max w-full flex-col items-start gap-1",
className,
)}
>
{children as never /* RAC accepts render-prop children */}
</AriaTextField>
</TextFieldContext.Provider>
);
}
TextField.displayName = "TextField";
/* -------------------------------------------------------------------------- */
/* InputBase */
/* -------------------------------------------------------------------------- */
export interface InputBaseProps extends Omit<AriaInputProps, "size" | "className"> {
size?: InputSize;
className?: string;
leadingIcon?: IconComponent;
trailingIcon?: IconComponent;
/** Custom element rendered in the leading slot (Phone basic uses this). */
leadingAddon?: ReactNode;
/** Class for the field shell. */
fieldClassName?: string;
/** Ref to the <input> element. */
ref?: Ref<HTMLInputElement>;
/** Ref to the field shell wrapper. */
groupRef?: Ref<HTMLDivElement>;
}
const inputStyles = sortCx({
field: [
"relative flex w-full items-center",
"rounded-2lg",
"bg-background-tertiary-default text-foreground-icon-tertiary",
"ring-2 ring-inset ring-transparent",
"transition-[background-color,box-shadow,color] duration-[var(--input-transition-ms)] ease",
].join(" "),
fieldSize: {
medium: "p-2", // 8px all sides → h auto = 36
small: "h-8 px-1.5 py-2", // 32 / 6 / 8
},
// When a leadingAddon is present (Phone basic): tighten left padding.
fieldWithAddonSize: {
medium: "h-9 pl-1 pr-2 py-2", // 36 / 4 / 8 / 8
small: "h-8 pl-1 pr-1.5 py-2",
},
content: "flex w-full items-center gap-2 min-w-0",
leftSection: "flex flex-1 items-center gap-0.5 min-w-0",
input: [
"min-w-0 flex-1 bg-transparent border-0 outline-none p-0 m-0",
"font-sans text-body-regular text-text-primary pl-1",
"placeholder:text-text-tertiary",
"focus:placeholder:text-text-primary",
"disabled:text-input-disabled-text disabled:placeholder:text-input-disabled-text",
"disabled:cursor-not-allowed",
"aria-invalid:placeholder:text-text-error-placeholder",
].join(" "),
icon: "size-5 shrink-0",
});
export function InputBase({
size: sizeProp,
leadingIcon: Leading,
trailingIcon: Trailing,
leadingAddon,
fieldClassName,
className,
ref,
groupRef,
...inputProps
}: InputBaseProps) {
const ctx = useContext(TextFieldContext);
const size: InputSize = sizeProp ?? ctx.size ?? "medium";
const hasAddon = leadingAddon !== undefined && leadingAddon !== null;
return (
<AriaGroup
ref={groupRef}
className={({ isFocusWithin, isHovered, isDisabled, isInvalid }) =>
cx(
inputStyles.field,
hasAddon
? inputStyles.fieldWithAddonSize[size]
: inputStyles.fieldSize[size],
// Hover: idle, no focus, no disabled, no invalid
isHovered &&
!isFocusWithin &&
!isDisabled &&
!isInvalid &&
"ring-border-button-hover",
// Focus wins over hover
isFocusWithin &&
!isDisabled &&
!isInvalid &&
"ring-border-button-active",
// Disabled
isDisabled &&
"bg-input-disabled-background text-input-disabled-foreground",
// Invalid
isInvalid && "bg-background-tertiary-error text-foreground-icon-error",
ctx.fieldClassName,
fieldClassName,
)
}
>
<div className={inputStyles.content}>
<div className={inputStyles.leftSection}>
{hasAddon ? (
leadingAddon
) : Leading ? (
<Leading className={inputStyles.icon} aria-hidden />
) : null}
<AriaInput
ref={ref}
{...inputProps}
className={cx(inputStyles.input, ctx.inputClassName, className)}
/>
</div>
{Trailing ? (
<Trailing className={inputStyles.icon} aria-hidden />
) : null}
</div>
</AriaGroup>
);
}
InputBase.displayName = "InputBase";
/* -------------------------------------------------------------------------- */
/* Input (composed) */
/* -------------------------------------------------------------------------- */
export interface InputProps
extends Omit<TextFieldProps, "children">,
Pick<
InputBaseProps,
| "leadingIcon"
| "trailingIcon"
| "leadingAddon"
| "fieldClassName"
| "groupRef"
| "ref"
> {
label?: ReactNode;
hint?: ReactNode;
/** Show an info icon next to the label. Replace with tooltip when Tooltip lands. */
tooltip?: boolean | string;
placeholder?: string;
}
export function Input({
label,
hint,
tooltip,
placeholder,
leadingIcon,
trailingIcon,
leadingAddon,
fieldClassName,
ref,
groupRef,
className,
...textFieldProps
}: InputProps) {
return (
<TextField
{...textFieldProps}
className={className}
// Don't clobber an explicit aria-label; fall back to the placeholder
// only for unlabelled fields that don't provide one.
aria-label={
textFieldProps["aria-label"] ??
(!label && typeof placeholder === "string" ? placeholder : undefined)
}
>
{({ isRequired, isInvalid }) => (
<>
{label && (
<Label
isRequired={isRequired}
isInvalid={isInvalid}
tooltip={tooltip}
>
{label}
</Label>
)}
<InputBase
ref={ref}
groupRef={groupRef}
placeholder={placeholder}
leadingIcon={leadingIcon}
trailingIcon={trailingIcon}
leadingAddon={leadingAddon}
fieldClassName={fieldClassName}
/>
{hint && <HintText isInvalid={isInvalid}>{hint}</HintText>}
</>
)}
</TextField>
);
}
Input.displayName = "Input";"use client";
import type { ReactNode, Ref } from "react";
import type { TextProps as AriaTextProps } from "react-aria-components";
import { Text as AriaText } from "react-aria-components";
import { cx } from "@/utils/cx";
export interface HintTextProps extends AriaTextProps {
children: ReactNode;
isInvalid?: boolean;
ref?: Ref<HTMLElement>;
}
export function HintText({
isInvalid = false,
className,
...props
}: HintTextProps) {
return (
<AriaText
slot={isInvalid ? "errorMessage" : "description"}
{...props}
className={cx(
"pt-px text-caption-1-medium text-text-secondary",
isInvalid && "text-text-error-primary",
className,
)}
/>
);
}"use client";
import type { ReactNode, Ref } from "react";
import type { TextProps as AriaTextProps } from "react-aria-components";
import { Text as AriaText } from "react-aria-components";
import { cx } from "@/utils/cx";
export interface HintTextProps extends AriaTextProps {
children: ReactNode;
isInvalid?: boolean;
ref?: Ref<HTMLElement>;
}
export function HintText({
isInvalid = false,
className,
...props
}: HintTextProps) {
return (
<AriaText
slot={isInvalid ? "errorMessage" : "description"}
{...props}
className={cx(
"pt-px text-caption-1-medium text-text-secondary",
isInvalid && "text-text-error-primary",
className,
)}
/>
);
}"use client";
import type { ReactNode, Ref } from "react";
import { RiInformationFill } from "@remixicon/react";
import type { LabelProps as AriaLabelProps } from "react-aria-components";
import { Label as AriaLabel } from "react-aria-components";
import { cx } from "@/utils/cx";
export interface LabelProps extends AriaLabelProps {
children: ReactNode;
isRequired?: boolean;
/** Reserved for invalid-aware styling (asterisk color, etc.) once we need it. */
isInvalid?: boolean;
/** Show the info icon next to the label. */
tooltip?: boolean | string;
ref?: Ref<HTMLLabelElement>;
}
export function Label({
isRequired = false,
isInvalid: _isInvalid,
tooltip,
className,
children,
...props
}: LabelProps) {
void _isInvalid;
return (
<AriaLabel
data-label="true"
{...props}
className={cx(
"flex cursor-default items-center gap-0.5",
"text-body-medium text-text-primary",
className,
)}
>
{children}
{isRequired && (
<span
aria-hidden="true"
className="text-body-medium text-text-error-primary"
>
*
</span>
)}
{tooltip && (
<RiInformationFill
className="size-4 shrink-0 text-foreground-icon-quaternary"
aria-hidden
/>
)}
</AriaLabel>
);
}"use client";
import type { ReactNode, Ref } from "react";
import { RiInformationFill } from "@remixicon/react";
import type { LabelProps as AriaLabelProps } from "react-aria-components";
import { Label as AriaLabel } from "react-aria-components";
import { cx } from "@/utils/cx";
export interface LabelProps extends AriaLabelProps {
children: ReactNode;
isRequired?: boolean;
/** Reserved for invalid-aware styling (asterisk color, etc.) once we need it. */
isInvalid?: boolean;
/** Show the info icon next to the label. */
tooltip?: boolean | string;
ref?: Ref<HTMLLabelElement>;
}
export function Label({
isRequired = false,
isInvalid: _isInvalid,
tooltip,
className,
children,
...props
}: LabelProps) {
void _isInvalid;
return (
<AriaLabel
data-label="true"
{...props}
className={cx(
"flex cursor-default items-center gap-0.5",
"text-body-medium text-text-primary",
className,
)}
>
{children}
{isRequired && (
<span
aria-hidden="true"
className="text-body-medium text-text-error-primary"
>
*
</span>
)}
{tooltip && (
<RiInformationFill
className="size-4 shrink-0 text-foreground-icon-quaternary"
aria-hidden
/>
)}
</AriaLabel>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
HintText
| Prop | Type | Default | Description |
|---|---|---|---|
| isInvalid | boolean | false | — |
Input
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | — |
| fieldClassName | string | — | — |
| groupRef | Ref<HTMLDivElement> | — | Ref to the field shell wrapper. |
| hint | ReactNode | — | — |
| inputClassName | string | — | — |
| label | ReactNode | — | — |
| leadingAddon | ReactNode | — | Custom element rendered in the leading slot (Phone basic uses this). |
| leadingIcon | IconComponent | — | — |
| placeholder | string | — | — |
| size | "medium" | "small" | — | — |
| tooltip | string | boolean | — | Show an info icon next to the label. Replace with tooltip when Tooltip lands. |
| trailingIcon | IconComponent | — | — |
InputBase
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | — |
| fieldClassName | string | — | Class for the field shell. |
| groupRef | Ref<HTMLDivElement> | — | Ref to the field shell wrapper. |
| leadingAddon | ReactNode | — | Custom element rendered in the leading slot (Phone basic uses this). |
| leadingIcon | IconComponent | — | — |
| size | "medium" | "small" | — | — |
| trailingIcon | IconComponent | — | — |
Label
| Prop | Type | Default | Description |
|---|---|---|---|
| isInvalid | boolean | — | Reserved for invalid-aware styling (asterisk color, etc.) once we need it. |
| isRequired | boolean | false | — |
| tooltip | string | boolean | — | Show the info icon next to the label. |
TextField
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | ((state: { isRequired: boolean; isInvalid: boolean; isDisabled: boolean; isReadOnly: boolean; }) => ReactNode) | — | The children of the component. A function may be provided to alter the children based on component state. |
| className | string | — | — |
| fieldClassName | string | — | — |
| inputClassName | string | — | — |
| size | "medium" | "small" | medium | — |