Photon Design System

Input Group

One field surface shared by a text control and its addons — prefix/suffix text, icons, keyboard hints, or a trailing button. The group carries the same hairline ring, sizes, and focus / invalid / disabled treatment as Input, so grouped and plain fields align in the same form, and clicking anywhere on the surface focuses the control. For a leading icon alone, plain Input with icon is enough — reach for the group when content must live inside the field.

⌘K
https://
https://

Import

import { InputGroup } from "@photon-ai/pho-ui/components/input-group";

Anatomy

Root is the bordered surface; everything else rides on it. Input is the control, Addon holds inline content on either side, and Separator draws a full-height hairline between segments.

<InputGroup.Root>
  <InputGroup.Addon>https://</InputGroup.Addon>
  <InputGroup.Input placeholder="example.com" />
  <InputGroup.Separator />
  <InputGroup.Addon>
    <Kbd>↵</Kbd>
  </InputGroup.Addon>
</InputGroup.Root>

Addons

Addon renders in the secondary tone so the value stays the loudest thing on the surface. It takes anything inline: plain text, a Tabler icon, Kbd caps, or a small ghost button for a trailing action.

{
  /* Search with a shortcut hint */
}
<InputGroup.Root>
  <InputGroup.Addon>
    <IconSearch />
  </InputGroup.Addon>
  <InputGroup.Input placeholder="Search projects…" aria-label="Search" />
  <InputGroup.Addon>
    <Kbd>⌘</Kbd>
    <Kbd>K</Kbd>
  </InputGroup.Addon>
</InputGroup.Root>;

{
  /* Read-only value with a copy action */
}
<InputGroup.Root>
  <InputGroup.Input
    readOnly
    defaultValue="pho_sk_51Kk…9fD3"
    aria-label="API key"
  />
  <InputGroup.Addon>
    <Button variant="ghost" size="sm" svgOnly aria-label="Copy API key">
      <IconCopy />
    </Button>
  </InputGroup.Addon>
</InputGroup.Root>;

Segments

Separator turns an addon into a boxed section — the classic https:// │ workspace │ .photon.dev arrangement. It’s a Base UI Separator (orientation="vertical") drawn with the site hairline, inset 2px from the top and bottom so it never overlaps the ring — resting or focused.

https://
.photon.dev
<InputGroup.Root>
  <InputGroup.Addon>https://</InputGroup.Addon>
  <InputGroup.Separator />
  <InputGroup.Input placeholder="workspace" aria-label="Subdomain" />
  <InputGroup.Separator />
  <InputGroup.Addon>.photon.dev</InputGroup.Addon>
</InputGroup.Root>

Sizes

The same three sizes as Input — sm, md, lg (default md) — landing the same 36 / 40 / 44px heights, so grouped and plain fields line up row for row. lg gets the same squircle corner smoothing as the lg Input. Addon icons scale with the size (16px at sm, 20px at md / lg).

⌘K
⌘K
⌘K
<InputGroup.Root size="sm">…</InputGroup.Root>
<InputGroup.Root size="md">…</InputGroup.Root>
<InputGroup.Root size="lg">…</InputGroup.Root>

With a label

InputGroup.Input renders Base UI Field.Control, so inside a Field.Root the label and description wire up automatically — no htmlFor bookkeeping. Standalone, give the control an aria-label instead.

https://

The domain your project deploys to.

<Field.Root>
  <Field.Label>Site URL</Field.Label>
  <InputGroup.Root>
    <InputGroup.Addon>https://</InputGroup.Addon>
    <InputGroup.Input placeholder="example.com" />
  </InputGroup.Root>
  <Field.Description>The domain your project deploys to.</Field.Description>
</Field.Root>

Invalid & disabled

invalid on Root renders the error ring and sets aria-invalid on the control. Disabling the control (disabled on InputGroup.Input) dims the whole surface and blocks the click-to-focus behavior.

<InputGroup.Root invalid>
  <InputGroup.Addon>
    <IconCreditCard />
  </InputGroup.Addon>
  <InputGroup.Input defaultValue="4242 4242 4242" aria-label="Card number" />
</InputGroup.Root>

<InputGroup.Root>
  <InputGroup.Addon>https://</InputGroup.Addon>
  <InputGroup.Input placeholder="Unavailable" disabled />
</InputGroup.Root>

Props

InputGroup.Root

  • size — sm · md · lg (default md) — the Input size scale
  • invalid — boolean — error ring on the surface, aria-invalid on the control
  • …native <div> attributes

InputGroup.Input

  • …native <input> attributes — value, onChange, placeholder, disabled, aria-*, … (everything except size, which lives on Root)

InputGroup.Addon

  • …native <div> attributes — content is anything inline: text, icons, Kbd, a small button

InputGroup.Separator

  • …Base UI Separator props — orientation defaults to vertical

Accessibility

  • Labelling — the control is Base UI Field.Control: inside a Field.Root it inherits the label/description wiring; standalone, pass aria-label. Addon text is visual context, not a label — screen readers don’t associate https:// with the field on their own.
  • Click-to-focus — pointer-down anywhere on the surface focuses the control, except on interactive children (buttons, links), which keep their own behavior and don’t steal focus from the field.
  • Invalid — invalid styles the surface and sets aria-invalid on the control, so the state is announced, not just painted.
  • Addon buttons — an icon-only action inside an addon needs svgOnly and an aria-label, same as any icon button.

Best practices

  • Use an addon for content that’s part of the value’s context — a protocol prefix, a unit, a shortcut hint. Put field-level actions like “Forgot?” in the label row, not inside the field.
  • Keep one control per group. Two inputs on one surface read as one field to the eye but two stops to the keyboard — use Fieldset for that.
  • Prefer plain Input when all you need is a leading icon; the group earns its extra markup only when addons carry real content.
  • Trailing buttons should be quiet (ghost, sm) — the field is the subject, the action is a convenience.