Skip to content

Input Group

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.

Inputv0.1.0experimentalWCAG 2.2 AAView spec
$
USD
Charged once, when you confirm.
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-group

Copies 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.json

Works, 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

https://
.example.com
Letters, numbers and dashes only.
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>
  );
}
"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

At least 12 characters.
"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.

PropTypeDefault
labelrequired

Visible label. Required so the field is always named.

stringnone
description

Help text shown under the group.

stringnone
errorMessage

Shown when the field is invalid.

stringnone
size

Height and font size of the whole group.

"sm" | "md" | "lg""md"
isDisabled

Prevents editing and dims the group. Buttons inside addons need their own isDisabled.

booleanfalse
isInvalid

Marks the value as invalid and shows errorMessage.

booleanfalse
isRequired

Marks the field as required for form submission.

booleanfalse
isReadOnly

The value can be selected and copied but not edited.

booleanfalse
value

Controlled value.

stringnone
defaultValue

Initial value when uncontrolled.

stringnone
onChange

Called with the new value on every edit.

(value: string) => voidnone
children

One InputGroupInput and any number of InputGroupAddon, in the order they should appear.

nodenone

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