A text input with leading and trailing slots, plus a Field that wires up label, description and error.
Shown in the sidebar and in URLs.
pnpm dlx shadcn@latest add @latheui/inputFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add motionAdd the Lathe items this component imports.
lib/motion.tsCopy the source into your project.
"use client"
import * as React from "react"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { duration, ease, useMotionSafe } from "@/lib/motion"
type FieldContextValue = {
id: string
describedBy?: string
invalid: boolean
}
const FieldContext = React.createContext<FieldContextValue | null>(null)
export type FieldProps = Omit<React.ComponentProps<"div">, "children"> & {
label: React.ReactNode
/** Helper text shown under the control. */
description?: React.ReactNode
/** Error text. When set, the control is marked invalid and this replaces the description. */
error?: React.ReactNode
/** Marks the label; pair it with `required` on the control. */
required?: boolean
/** A single control, usually an `Input`. */
children: React.ReactNode
}
/** Labels a control and wires its description and error for assistive tech. */
function Field({
label,
description,
error,
required,
className,
children,
...props
}: FieldProps) {
const id = React.useId()
const { safe } = useMotionSafe()
const messageId = `${id}-message`
const invalid = Boolean(error)
const message = error || description
return (
<FieldContext.Provider
value={{ id, invalid, describedBy: message ? messageId : undefined }}
>
<div data-slot="field" className={cn("flex flex-col gap-1.5", className)} {...props}>
<label htmlFor={id} className="text-sm font-medium text-foreground">
{label}
{required && (
<span aria-hidden="true" className="ml-0.5 text-destructive">
*
</span>
)}
</label>
{children}
<div id={messageId} aria-live="polite">
<AnimatePresence initial={false} mode="wait">
{message && (
<motion.p
key={invalid ? "error" : "description"}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={safe({ duration: duration.fast, ease: ease.out })}
className={cn(
"overflow-hidden text-[13px] leading-5",
invalid ? "text-destructive" : "text-muted-foreground"
)}
>
{message}
</motion.p>
)}
</AnimatePresence>
</div>
</div>
</FieldContext.Provider>
)
}
export type InputProps = Omit<React.ComponentProps<"input">, "size"> & {
size?: "sm" | "md" | "lg"
/** Content before the text, such as an icon or a unit. */
startSlot?: React.ReactNode
/** Content after the text, such as a button or a shortcut hint. */
endSlot?: React.ReactNode
/** Marks the input invalid. Set automatically inside a `Field` with an error. */
invalid?: boolean
}
const sizes = {
// 16px on small screens stops iOS zooming the page on focus.
sm: "h-8 text-base md:text-[13px]",
md: "h-9 text-base md:text-sm",
lg: "h-11 text-base",
} as const
/** `className` styles the bordered wrapper; every other prop goes to the `<input>`. */
function Input({
size = "md",
startSlot,
endSlot,
invalid: invalidProp,
className,
id,
disabled,
"aria-describedby": describedBy,
...props
}: InputProps) {
const field = React.useContext(FieldContext)
const invalid = invalidProp ?? field?.invalid ?? false
return (
<div
data-slot="input"
data-invalid={invalid || undefined}
data-disabled={disabled || undefined}
className={cn(
"flex w-full min-w-0 items-center gap-2 rounded-md border border-input bg-background px-3 text-foreground",
"transition-[border-color,box-shadow] duration-150",
"focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20",
"data-invalid:border-destructive data-invalid:focus-within:ring-destructive/20",
"data-disabled:cursor-not-allowed data-disabled:opacity-50",
"[&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
sizes[size],
className
)}
>
{startSlot}
<input
id={id ?? field?.id}
disabled={disabled}
aria-invalid={invalid || undefined}
aria-describedby={describedBy ?? field?.describedBy}
className="h-full w-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground/70 disabled:cursor-not-allowed"
{...props}
/>
{endSlot}
</div>
)
}
export { Field, Input }Depends on
className styles the bordered wrapper. Every other prop, including ref, goes to the native input.
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | "md" | Control height. Replaces the native size attribute. |
| startSlot | ReactNode | — | Content before the text, such as an icon or a currency symbol. |
| endSlot | ReactNode | — | Content after the text, such as a unit, a shortcut hint or a button. |
| invalid | boolean | — | Marks the input invalid. Set automatically inside a Field that has an error. |
Also accepts native div attributes.
| Prop | Type | Default | Description |
|---|---|---|---|
| label* (required) | ReactNode | — | The visible label, linked to the control. |
| description | ReactNode | — | Helper text under the control. |
| error | ReactNode | — | Error text. Marks the control invalid and replaces the description. |
| required | boolean | false | Adds a marker to the label. Also set required on the control. |
| children* (required) | ReactNode | — | One control, usually an Input. |
<Input
aria-label="Coupon code"
placeholder="Coupon code"
className="pr-1"
endSlot={
<Button size="sm" variant="secondary" className="h-7">
Apply
</Button>
}
/>