Text Field
A styled single-line text input with invalid and file styles.
Preview
Profile
Visible to everyone in your workspace.
Shown on comments and invoices.
Available
Add the part after the @.
$
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/text-field.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| type | string | "text" | Native input type, including file |
| leading | ReactNode | — | Icon or short text at the start of the field (16 px, decorative) |
| trailing | ReactNode | — | Content at the end of the field, such as a unit or a button |
| valid | boolean | false | Shows a success check at the end of the field |
| rootClassName | string | — | Classes for the wrapper rendered when leading, trailing or valid is set |
| aria-invalid | boolean | — | Danger border and ring |
| disabled | boolean | false | Not interactive, 50% opacity |
| ...props | React.ComponentProps<"input"> | — | All native input props |
Build notes
Plain `<input>` with no state, so it stays a server-compatible component (no `"use client"`). Invalid styling keys off the `aria-invalid` attribute (`aria-invalid:` variant), so the accessible state and the visual state can't drift. File styling uses Tailwind's `file:` variant on the `::file-selector-button` pseudo-element. Renamed from shadcn's `Input` to `TextField`; the slot is `text-field`. **Pebble (2026-11-03).** Field is a recessed well: `bg-card`, full-strength `border-control`, `inset-shadow-xs`; hover darkens the edge; focus uses the 2 px ring with offset; disabled fills `bg-muted`. Added optional `leading`, `trailing`, `valid` and `rootClassName`. Without them the component is still a bare `<input>`, so existing layouts are untouched; with them a `group/field` wrapper positions 36 px adornment columns and pads the input. The leading icon turns foreground on `focus-within`. `valid` adds a success-filled tick that pops in via `@starting-style` (`starting:scale-50 starting:opacity-0`), so no client JS is needed and the file stays a server component; reduced motion drops the transition. The wrapper is chosen by value: a set adornment, `valid`, or a string `rootClassName`. An earlier version chose it by key presence (`key in props`), which wrapped every field rendered through a component that forwards the props (even as `undefined`) and silently moved `className` off the layout item; value-based keeps such wrappers bare. Fields whose adornments toggle pass `rootClassName` (even `""`) as the explicit opt-in, so the wrapper stays mounted and the input keeps its focus and uncontrolled value. <!-- PEBBLE-LICENSE -->
View source
registry/input/text-field/text-field.tsx
/* PEBBLE-LICENSE
Built by Pebble. Component: text-field.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THIRD-PARTY NOTICES
These notices apply to identified upstream portions; Pebble terms do not replace or narrow their rights.
Upstream: https://raw.githubusercontent.com/shadcn-ui/ui/main/LICENSE.md
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { cn } from "@/lib/utils";
export type TextFieldProps = React.ComponentProps<"input"> & {
/** Decoration before the text, usually a 16 px icon. Not interactive. */
leading?: React.ReactNode;
/** Content after the text: a unit, a hint key or a small button. */
trailing?: React.ReactNode;
/** Marks the value as checked and good: a success tick at the end. */
valid?: boolean;
/**
* Classes for the wrapper. The wrapper exists while an adornment is set or
* `rootClassName` is a string (`""` included), and it is then the layout
* item: put `flex-1`, widths and grid placement here, not on `className`.
* Pass it on fields whose adornments toggle, so the wrapper (and the input
* with its focus) stays mounted.
*/
rootClassName?: string;
};
/**
* `null`, `undefined`, booleans, `""`, arrays of only those and empty
* fragments render nothing, so they count as absent.
*/
function present(node: React.ReactNode): boolean {
if (node == null || typeof node === "boolean" || node === "") return false;
if (Array.isArray(node)) return node.some(present);
if (React.isValidElement<{ children?: React.ReactNode }>(node) && node.type === React.Fragment) {
return present(node.props.children);
}
return true;
}
export function TextField({
className,
type,
leading,
trailing,
valid,
rootClassName,
...props
}: TextFieldProps) {
const hasLeading = present(leading);
const hasTrailing = present(trailing) || !!valid;
// The wrapper follows values, so a component that forwards
// `leading={leading}` still renders a bare <input> (with `className` on it)
// when nothing is set. Swapping between bare and wrapped remounts the input,
// so fields whose adornments toggle opt in by passing `rootClassName`.
const wrapped = hasLeading || hasTrailing || rootClassName !== undefined;
const input = (
<input
type={type}
data-slot="text-field"
data-valid={valid || undefined}
className={cn(
// A recessed well: card face, control edge at full strength (the
// scripted 3:1 token) and a soft inner shade from above.
"h-9 w-full min-w-0 rounded-md border border-border-control bg-card px-3 py-1 text-base text-foreground inset-shadow-xs md:text-sm",
"transition-[border-color,box-shadow,background-color] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
"selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground",
"hover:border-muted-foreground",
"file:me-3 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground",
"outline-none focus-visible:border-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"aria-invalid:border-danger aria-invalid:ring-[3px] aria-invalid:ring-danger/20 aria-invalid:focus-visible:ring-2 aria-invalid:focus-visible:ring-danger/40",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 disabled:inset-shadow-none",
hasLeading && "ps-9",
hasTrailing && "pe-9",
className,
)}
{...props}
/>
);
// With none of the wrapper props passed, the field stays a bare <input>.
if (!wrapped) return input;
return (
<div data-slot="text-field-root" className={cn("group/field relative w-full", rootClassName)}>
{hasLeading && (
<span
data-slot="text-field-leading"
className="pointer-events-none absolute inset-y-0 start-0 flex w-9 items-center justify-center text-muted-foreground transition-colors duration-(--motion-fast) group-focus-within/field:text-foreground group-has-disabled/field:opacity-50 motion-reduce:transition-none [&_svg]:size-4 [&_svg]:shrink-0"
>
{leading}
</span>
)}
{hasTrailing && (
<span
data-slot="text-field-trailing"
className="pointer-events-none absolute inset-y-0 end-0 flex min-w-9 items-center justify-center gap-1.5 ps-1 pe-2.5 text-[13px] text-muted-foreground tabular-nums group-has-disabled/field:opacity-50 [&_a]:pointer-events-auto [&_button]:pointer-events-auto [&_svg]:size-4 [&_svg]:shrink-0"
>
{present(trailing) && trailing}
{valid && (
<span
data-slot="text-field-valid"
// Pops in when the field turns valid (and once on first paint).
className="grid size-4 place-content-center rounded-full bg-success text-card transition-[scale,opacity] duration-(--motion-base) ease-enter motion-reduce:transition-none starting:scale-50 starting:opacity-0"
>
<svg
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="size-2.5!"
>
<path d="M3 6.25 5 8.25l4-4.5" />
</svg>
</span>
)}
</span>
)}
{input}
</div>
);
}
