One bordered text field that wraps an input with addons before or after it: a currency sign, a URL prefix, a search icon, a clear, show or copy button. Built from InputGroup, InputGroupAddon and InputGroupInput.
import { InputGroup, InputGroupAddon, InputGroupInput } from "@rdloom/react";
export default function InputGroupCurrencyExample() {
return (
<div className="flex w-full justify-center">
<InputGroup label="Amount" description="Charged once, when you confirm." className="w-64">
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput inputMode="decimal" placeholder="0.00" />
<InputGroupAddon align="end">USD</InputGroupAddon>
</InputGroup>
</div>
);
}Installation
npx rdloom add input-groupCopies the source into src/components/rdloom. Edit it freely: rdloom upgrade merges later versions into your changes. It needs react-aria-components; add --install to install them.
Use another registry client
npx shadcn@latest add https://rdloom.vimalbhatt.com/r/input-group.jsonWorks, but without upgrade tracking.
Usage
import { InputGroup, InputGroupAddon, InputGroupInput } from "@rdloom/react";
<div className="flex w-full justify-center">
<InputGroup label="Amount" description="Charged once, when you confirm." className="w-64">
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput inputMode="decimal" placeholder="0.00" />
<InputGroupAddon align="end">USD</InputGroupAddon>
</InputGroup>
</div>Url
import { InputGroup, InputGroupAddon, InputGroupInput } from "@rdloom/react";
export default function InputGroupUrlExample() {
return (
<div className="flex w-full justify-center">
<InputGroup label="Workspace address" description="Letters, numbers and dashes only." className="w-80">
<InputGroupAddon>https://</InputGroupAddon>
<InputGroupInput placeholder="acme" />
<InputGroupAddon align="end">.example.com</InputGroupAddon>
</InputGroup>
</div>
);
}Search
"use client";
import { useRef, useState } from "react";
import { Button, InputGroup, InputGroupAddon, InputGroupInput } from "@rdloom/react";
const icon = { "aria-hidden": true, viewBox: "0 0 16 16", width: 16, height: 16, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" } as const;
export default function InputGroupSearchExample() {
const [value, setValue] = useState("invoice");
const input = useRef<HTMLInputElement>(null);
return (
<div className="flex w-full justify-center">
<InputGroup label="Search invoices" value={value} onChange={setValue} className="w-72">
<InputGroupAddon type="icon">
<svg {...icon}><circle cx="7" cy="7" r="4.5" /><path d="M10.5 10.5L14 14" /></svg>
</InputGroupAddon>
<InputGroupInput ref={input} placeholder="Customer or number" />
{value && (
<InputGroupAddon type="button" align="end">
<Button
variant="ghost"
size="sm"
aria-label="Clear search"
className="aspect-square px-0!"
onPress={() => {
setValue("");
input.current?.focus();
}}
>
<svg {...icon}><path d="M4 4l8 8M12 4l-8 8" /></svg>
</Button>
</InputGroupAddon>
)}
</InputGroup>
</div>
);
}Password
"use client";
import { useState } from "react";
import { Button, InputGroup, InputGroupAddon, InputGroupInput } from "@rdloom/react";
const icon = { "aria-hidden": true, viewBox: "0 0 16 16", width: 16, height: 16, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" } as const;
export default function InputGroupPasswordExample() {
const [shown, setShown] = useState(false);
return (
<div className="flex w-full justify-center">
<InputGroup label="Password" description="At least 12 characters." isRequired className="w-72">
<InputGroupInput type={shown ? "text" : "password"} autoComplete="new-password" />
<InputGroupAddon type="button" align="end">
{/* The name stays the same; aria-pressed says whether the password is visible. */}
<Button
variant="ghost"
size="sm"
aria-label="Show password"
aria-pressed={shown}
className="aspect-square px-0!"
onPress={() => setShown((v) => !v)}
>
<svg {...icon}>
<path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" />
<circle cx="8" cy="8" r="2" />
{shown && <path d="M2.5 13.5l11-11" />}
</svg>
</Button>
</InputGroupAddon>
</InputGroup>
</div>
);
}Copy
"use client";
import { useState } from "react";
import { Button, InputGroup, InputGroupAddon, InputGroupInput } from "@rdloom/react";
const icon = { "aria-hidden": true, viewBox: "0 0 16 16", width: 16, height: 16, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" } as const;
const link = "https://example.com/invite/k3x9qw";
export default function InputGroupCopyExample() {
const [copied, setCopied] = useState(false);
return (
<div className="flex w-full justify-center">
<InputGroup label="Invite link" defaultValue={link} isReadOnly className="w-80">
<InputGroupInput />
<InputGroupAddon type="button" align="end">
<Button
variant="ghost"
size="sm"
aria-label="Copy link"
className="aspect-square px-0!"
onPress={() => {
void navigator.clipboard?.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? (
<svg {...icon}><path d="M3 8.5l3.5 3.5L13 4.5" /></svg>
) : (
<svg {...icon}><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 5.5v-2a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2" /></svg>
)}
</Button>
</InputGroupAddon>
<span role="status" className="sr-only">{copied ? "Link copied" : ""}</span>
</InputGroup>
</div>
);
}API Reference
Defined by the spec. Components also accept the props of the React Aria component they wrap.
| Prop | Type | Default |
|---|---|---|
labelrequiredVisible label. Required so the field is always named. | string | none |
descriptionHelp text shown under the group. | string | none |
errorMessageShown when the field is invalid. | string | none |
sizeHeight and font size of the whole group. | "sm" | "md" | "lg" | "md" |
isDisabledPrevents editing and dims the group. Buttons inside addons need their own isDisabled. | boolean | false |
isInvalidMarks the value as invalid and shows errorMessage. | boolean | false |
isRequiredMarks the field as required for form submission. | boolean | false |
isReadOnlyThe value can be selected and copied but not edited. | boolean | false |
valueControlled value. | string | none |
defaultValueInitial value when uncontrolled. | string | none |
onChangeCalled with the new value on every edit. | (value: string) => void | none |
childrenOne InputGroupInput and any number of InputGroupAddon, in the order they should appear. | node | none |
Accessibility
Role textbox, WCAG 2.2 AA. Tested with axe and keyboard tests; screen reader checks are in the audit checklist.
Keyboard
- Tab moves to the input, then to any button in an addon, in visual order
- Typing, selecting and clipboard keys work as in a normal text field
- Enter or Space activates a focused addon button
Screen readers announce
- Announced as an edit text with its label, for example "Amount, edit text, USD"
- Help text, error messages and addon text are all read as the description of the input
- A required field is announced as required
What your code must do
- The input is named by the visible label; the addons are not part of the name
- Text addons (a currency, a URL prefix) are linked to the input with aria-describedby, so they are read after the label, with the help text, and not lost
- Icon addons are decorative and hidden from assistive technology
- A button in an addon has its own accessible name; an icon-only one needs an aria-label
- The focus ring is drawn around the whole group when the input has focus; a focused addon button has its own ring
- Invalid is shown by the border color and by the error message, which is linked with aria-describedby
- Disabled and read-only are exposed on the input itself
Guidelines
Use it when
- A value needs a fixed unit or prefix, like $ or https://
- A search box with a leading icon and a clear button
- A password with a show button, or a read-only value with a copy button
Avoid it when
- A plain text field: use TextField
- Several separate inputs in a row, like a date or a code: use their own components
- More than one or two addons on each side: the input gets too narrow
Don't
- Using an addon as the only label of the field
- An icon-only addon button without an aria-label
- Putting long text in an addon
Design tokens
The semantic tokens this component uses. Change them once and every component follows; see Design tokens.
--rd-color-surface-default--rd-color-surface-subtle--rd-color-border-default--rd-color-border-strong--rd-color-text-default--rd-color-text-muted--rd-color-feedback-danger--rd-color-focus-ring--rd-radius-control