Input Group

Puts icons, text and buttons inside a single input frame.

12 results
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText } from "@/components/ui/input-group"
import { ArrowUp, Search } from "lucide-react"
import { VStack } from "@/components/ui/stack"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "20rem" },
})

export default function InputGroupDemo() {
  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup>
        <Input placeholder="Search projects" aria-label="Search projects" />
        <InputGroupAddon>
          <Search />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <InputGroupText>12 results</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="Ask a question" aria-label="Ask a question" />
        <InputGroupAddon align="inline-end">
          <InputGroupButton variant="default" size="icon-xs" aria-label="Send">
            <ArrowUp />
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </VStack>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts from the same page. The frame's field surface, its focus ring, its invalid ring and its disabled dim come from it. The group also needs interop.css, which sizes the icons its addons do not render.

Paste field-state.ts and state-styles.ts from the same installation page.

Add Input and Button. The group imports both, and InputGroupContext is declared in input.tsx so the input stays pasteable without the group.

Copy and paste the following code into your project.

components/ui/input-group.tsx
"use client"

import { FieldStateContext } from "@/lib/field-state"
import { useRender } from "@base-ui/react/use-render"
import type { ComponentProps, FocusEvent, MouseEvent } from "react"
import { createContext, use, useState } from "react"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { Button, type ButtonSize, type ButtonVariant } from "@/components/ui/button"
import { InputGroupContext } from "@/components/ui/input"
import { states } from "@/theme/states"
import { color, duration, ease, leading, radius, space, text } from "@/theme/tokens.stylex"

type InputGroupAlign = "inline-start" | "inline-end" | "block-start" | "block-end"

const InputGroupAddonContext = createContext<{ align: InputGroupAlign } | null>(null)

const styles = stylex.create({
  group: {
    display: "flex",
    flexWrap: "wrap",
    alignItems: "center",
    alignContent: "center",
    width: "100%",
    minWidth: 0,
    minHeight: space.s8,
    borderRadius: radius.lg,
    borderWidth: 1,
    borderStyle: "solid",
    borderColor: "var(--carbon-border-color)",
    transitionProperty: "color, background-color, border-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    outlineStyle: "none",
  },
  disabledFill: { backgroundColor: color.inputDisabledBg },
  addon: {
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    gap: space.s2,
    cursor: "text",
    userSelect: "none",
    color: color.mutedForeground,
    fontSize: text.sm,
    lineHeight: leading.sm,
    fontWeight: 500,
    "--carbon-icon-size": space.s4,
  },
  text: {
    display: "flex",
    alignItems: "center",
    gap: space.s2,
    color: color.mutedForeground,
    fontSize: text.sm,
    lineHeight: leading.sm,
    "--carbon-icon-size": space.s4,
  },
  pullStart: { marginInlineStart: `calc(-1 * ${space.s1})` },
  pullEnd: { marginInlineEnd: `calc(-1 * ${space.s1})` },
})

const buttons = stylex.create({
  xs: { boxShadow: "none", "--carbon-button-radius": `calc(${radius.lg} - ${space.s1})` },
  sm: { boxShadow: "none", "--carbon-button-radius": `calc(${radius.lg} - ${space.s0_5})` },
})

const aligns = stylex.create({
  "inline-start": {
    order: -1,
    paddingInlineStart: space.s2,
    marginInlineEnd: `calc(-1 * ${space.s1})`,
  },
  "inline-end": {
    order: 1,
    paddingInlineEnd: space.s2,
    marginInlineStart: `calc(-1 * ${space.s1})`,
  },
  "block-start": {
    order: -2,
    flexBasis: "100%",
    justifyContent: "flex-start",
    paddingInline: space.s2_5,
    paddingBlockStart: space.s2,
  },
  "block-end": {
    order: 2,
    flexBasis: "100%",
    justifyContent: "flex-start",
    paddingInline: space.s2_5,
    paddingBlockEnd: space.s2,
  },
})

const inputGroupStyles = {
  base: [styles.group, states.fieldSurface],
  focused: states.fieldFocused,
  invalid: states.invalidRing,
  disabled: [styles.disabledFill, states.disabled],
  addon: styles.addon,
  aligns,
  text: styles.text,
  buttons,
}

type Overrides = { xstyle?: StyleXStyles; className?: string }

type InputGroupProps = Omit<
  useRender.ComponentProps<"div", { disabled: boolean }>,
  "onFocus" | "onBlur"
> &
  Overrides & {
    disabled?: boolean
    invalid?: boolean
    onFocus?: (event: FocusEvent<HTMLDivElement>) => void
    onBlur?: (event: FocusEvent<HTMLDivElement>) => void
  }

function InputGroup({
  render,
  ref,
  style,
  disabled: disabledProp = false,
  invalid: invalidProp = false,
  xstyle,
  className,
  onFocus,
  onBlur,
  ...props
}: InputGroupProps) {
  const fieldState = use(FieldStateContext)
  const disabled = disabledProp || fieldState?.disabled === true
  const invalid = invalidProp || fieldState?.valid === false
  const [focused, setFocused] = useState(false)

  const sx = stylex.props(
    styles.group,
    states.fieldSurface,
    focused && states.fieldFocused,
    invalid && states.invalidRing,
    disabled && styles.disabledFill,
    disabled && states.disabled,
    xstyle,
  )

  const isControl = (target: EventTarget) =>
    target instanceof Element && target.matches('[data-slot="input-group-control"]')

  return (
    <InputGroupContext value={{ disabled, invalid }}>
      {/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role, jsx-a11y/no-noninteractive-element-interactions */}
      {useRender({
        defaultTagName: "div",
        render,
        ref,
        state: { disabled },
        props: {
          role: "group",
          "data-slot": "input-group",
          ...sx,
          ...props,
          onFocus: (event: FocusEvent<HTMLDivElement>) => {
            if (isControl(event.target)) setFocused(true)
            onFocus?.(event)
          },
          onBlur: (event: FocusEvent<HTMLDivElement>) => {
            if (isControl(event.target)) setFocused(false)
            onBlur?.(event)
          },
          className: clsx(sx.className, className),
          style: { ...sx.style, ...style },
        },
      })}
    </InputGroupContext>
  )
}

type InputGroupAddonProps = useRender.ComponentProps<"div"> &
  Overrides & { align?: InputGroupAlign }

function InputGroupAddon({
  render,
  ref,
  style,
  align = "inline-start",
  xstyle,
  className,
  onClick,
  ...props
}: InputGroupAddonProps) {
  const sx = stylex.props(styles.addon, aligns[align] ?? aligns["inline-start"], xstyle)

  return (
    <InputGroupAddonContext value={{ align }}>
      {/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
      {useRender({
        defaultTagName: "div",
        render,
        ref,
        props: {
          "data-slot": "input-group-addon",
          "data-align": align,
          ...sx,
          ...props,
          onClick: (event: MouseEvent<HTMLDivElement>) => {
            if (!(event.target as HTMLElement).closest("button")) {
              event.currentTarget.parentElement
                ?.querySelector<HTMLElement>('[data-slot="input-group-control"]')
                ?.focus()
            }
            onClick?.(event)
          },
          className: clsx("carbon-icons", sx.className, className),
          style: { ...sx.style, ...style },
        },
      })}
    </InputGroupAddonContext>
  )
}

type InputGroupButtonProps = Omit<ComponentProps<typeof Button>, "size"> & {
  size?: Extract<ButtonSize, "xs" | "sm" | "icon-xs" | "icon-sm">
  variant?: ButtonVariant
}

function InputGroupButton({
  variant = "ghost",
  size = "xs",
  xstyle,
  ...props
}: InputGroupButtonProps) {
  const align = use(InputGroupAddonContext)?.align

  return (
    <Button
      data-slot="input-group-button"
      variant={variant}
      size={size}
      xstyle={[
        buttons[size === "xs" || size === "icon-xs" ? "xs" : "sm"],
        align === "inline-start" && styles.pullStart,
        align === "inline-end" && styles.pullEnd,
        xstyle,
      ]}
      {...props}
    />
  )
}

function InputGroupText({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"span"> & Overrides) {
  const sx = stylex.props(styles.text, xstyle)

  return useRender({
    defaultTagName: "span",
    render,
    ref,
    props: {
      "data-slot": "input-group-text",
      ...sx,
      ...props,
      className: clsx("carbon-icons", sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

export {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupText,
  inputGroupStyles,
  type InputGroupAlign,
}

Update the import paths to match your project setup.

Usage

import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText } from "@/components/ui/input-group"
<InputGroup>
  <Input placeholder="Search" aria-label="Search" />
  <InputGroupAddon>
    <Search />
  </InputGroupAddon>
</InputGroup>

Composition

InputGroup
├── Input
└── InputGroupAddon
    ├── InputGroupButton
    └── InputGroupText

There is no InputGroupInput. Input finds the group through context and drops its own border, fill and ring, so only the group draws a frame. Any other control joins the frame when it has data-slot="input-group-control".

There is no InputGroupTextarea either. A Textarea joins the frame through the same data-slot, plus textareaStyles.inGroup, which removes the textarea's own frame.

Align

Use the align prop on InputGroupAddon to place the addon. The inline values put the addon beside the control. The block values put it on its own row and make the frame taller.

https://
.com
Domain
Lowercase letters and dashes only.
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
import { VStack } from "@/components/ui/stack"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "20rem" },
})

export default function InputGroupAlign() {
  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup>
        <Input placeholder="inline-start" aria-label="inline-start" />
        <InputGroupAddon align="inline-start">
          <InputGroupText>https://</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="inline-end" aria-label="inline-end" />
        <InputGroupAddon align="inline-end">
          <InputGroupText>.com</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="block-start" aria-label="block-start" />
        <InputGroupAddon align="block-start">
          <InputGroupText>Domain</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="block-end" aria-label="block-end" />
        <InputGroupAddon align="block-end">
          <InputGroupText>Lowercase letters and dashes only.</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
    </VStack>
  )
}

Render every addon after the control. align positions it with order, so the control stays first in the DOM and therefore first in the tab order. An addon written before the control appears in the same place but comes before the control in the tab order.

Icon

import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
import { VStack } from "@/components/ui/stack"
import { Check, CreditCard, Search } from "lucide-react"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "20rem" },
})

export default function InputGroupIcon() {
  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup>
        <Input placeholder="Search" aria-label="Search" />
        <InputGroupAddon>
          <Search />
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="Card number" aria-label="Card number" />
        <InputGroupAddon>
          <CreditCard />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <Check />
        </InputGroupAddon>
      </InputGroup>
    </VStack>
  )
}

Text

Use InputGroupText for a prefix, a unit or a counter.

$
USD
@carbon.style
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
import { VStack } from "@/components/ui/stack"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "20rem" },
})

export default function InputGroupTextDemo() {
  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup>
        <Input placeholder="0.00" inputMode="decimal" aria-label="Amount" />
        <InputGroupAddon>
          <InputGroupText>$</InputGroupText>
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <InputGroupText>USD</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="ada" aria-label="Username" />
        <InputGroupAddon align="inline-end">
          <InputGroupText>@carbon.style</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
    </VStack>
  )
}

Button

Use InputGroupButton for an action inside the frame. It defaults to the ghost variant at size xs and shifts toward its addon's edge so its padding does not stack with the addon's.

"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupButton } from "@/components/ui/input-group"
import { VStack } from "@/components/ui/stack"
import { Eye, EyeOff } from "lucide-react"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "20rem" },
})

export default function InputGroupButtonDemo() {
  const [visible, setVisible] = useState(false)

  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup>
        <Input
          type={visible ? "text" : "password"}
          defaultValue="correcthorsebatterystaple"
          aria-label="Password"
        />
        <InputGroupAddon align="inline-end">
          <InputGroupButton
            size="icon-xs"
            aria-label={visible ? "Hide password" : "Show password"}
            onClick={() => setVisible(!visible)}
          >
            {visible ? <EyeOff /> : <Eye />}
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <Input placeholder="Type to search" aria-label="Search" />
        <InputGroupAddon align="inline-end">
          <InputGroupButton variant="secondary">Search</InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </VStack>
  )
}

Pass InputGroupButton to a menu trigger through render.

import * as stylex from "@stylexjs/stylex"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupButton } from "@/components/ui/input-group"
import { ChevronDown } from "lucide-react"

const styles = stylex.create({
  group: { maxWidth: "20rem" },
})

export default function InputGroupDropdown() {
  return (
    <InputGroup xstyle={styles.group}>
      <Input placeholder="Search" aria-label="Search" />
      <InputGroupAddon align="inline-end">
        <DropdownMenu>
          <DropdownMenuTrigger render={<InputGroupButton />}>
            Everywhere
            <ChevronDown />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end" sideOffset={8}>
            <DropdownMenuItem>Everywhere</DropdownMenuItem>
            <DropdownMenuItem>Documentation</DropdownMenuItem>
            <DropdownMenuItem>Changelog</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </InputGroupAddon>
    </InputGroup>
  )
}

Disabled and invalid

Set disabled or invalid on the enclosing Field or directly on the group. The group passes both to the control through context, so one prop styles the frame and marks the input.

import { Field, FieldLabel } from "@/components/ui/field"
import * as stylex from "@stylexjs/stylex"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
import { VStack } from "@/components/ui/stack"
import { Mail } from "lucide-react"

const styles = stylex.create({
  stack: { width: "100%", maxWidth: "20rem" },
})

export default function InputGroupStates() {
  return (
    <VStack gap={4} xstyle={styles.stack}>
      <InputGroup disabled>
        <Input defaultValue="me@example.com" aria-label="Email" />
        <InputGroupAddon>
          <Mail />
        </InputGroupAddon>
      </InputGroup>
      <InputGroup invalid>
        <Input defaultValue="not-an-email" aria-label="Email" />
        <InputGroupAddon>
          <Mail />
        </InputGroupAddon>
      </InputGroup>
      <Field invalid disabled>
        <FieldLabel>Email</FieldLabel>
        <InputGroup>
          <Input defaultValue="not-an-email" />
          <InputGroupAddon>
            <Mail />
          </InputGroupAddon>
        </InputGroup>
      </Field>
    </VStack>
  )
}

Setting disabled on the control alone greys the text but leaves the frame undimmed. The frame cannot read its children's state, because :has() is below this project's browser baseline. Set the state on the group instead.

API reference

Pass render to replace a part's element or compose it with another component.

Every part renders a div (or a span, for InputGroupText) and takes xstyle and className.

InputGroup

PropTypeDefault
disabledbooleanfalse
invalidbooleanfalse
xstyleStyleXStyles—
classNamestring—

The group draws its focus ring from React state. It sets that state from a focus event that bubbles up from an element with data-slot="input-group-control". A button inside an addon keeps its own ring and does not light the frame's ring.

InputGroupAddon

PropTypeDefault
align"inline-start" | "inline-end" | "block-start" | "block-end""inline-start"

Clicking an addon focuses the group's control, unless the click landed on a button.

InputGroupButton

PropTypeDefault
variantevery Button variant"ghost"
size"xs" | "sm" | "icon-xs" | "icon-sm""xs"

Takes every other Button prop. The larger Button sizes are left out because they are taller than the frame.

InputGroupText

A muted run of text at the control's size, with a 0.5rem gap for an icon.

inputGroupStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
baseThe frame: field surface, wrap layout, min height
focusedThe ring, with no pseudo-class on it, over the drop
invalidThe destructive border and ring
disabledThe disabled fill, dimmed and inert
addonAn addon's row, type and cursor
alignsOne key per align value, each an order and inset
textInputGroupText
buttonsAn in-frame button, flat and concentric, xs/sm

base is the frame's own box plus states.fieldSurface. focused is ring with the field's shadow kept under it. invalid is invalidRing. All three come from states in theme/states.ts. disabled is two styles, the group's own fill and the shared disabled. buttons.xs and buttons.sm set --carbon-button-radius to the frame's radius less the button's inset, for a 24px and a 28px button. The frame tracks focus itself, from a focus event the control raises, so its ring has no pseudo-class.

Data attributes

AttributeOnEffect
data-slot="input-group"the frameTargets the frame from a parent
data-disabledthe framePresent when disabled is set
data-slot="input-group-addon"an addonTargets an addon from a parent
data-alignan addonReflects the align prop
data-slot="input-group-button"a buttonTargets a button from a parent
data-slot="input-group-text"a text runTargets a text run from a parent
data-slot="input-group-control"the controlWhat the frame watches to draw its focus ring