rapidUI

Input Group

Input with leading/trailing addons, buttons and text slots.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/input-group.json

Installs to @/components/ui/input-group.

Manual installation

Install the dependencies:

pnpm add class-variance-authority@^0.7.1

Add the components it builds on: button, input, textarea.

Copy the source into your project:

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

import { cva, type VariantProps } from 'class-variance-authority'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'

function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
	return (
		<div
			data-slot="input-group"
			role="group"
			className={cn(
				'group/input-group relative flex h-10 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none hover:border-primary/90 has-[[data-slot=input-group-control]:read-only]:border-dashed has-[[data-slot=input-group-control]:read-only]:bg-muted/50 has-[[data-slot=input-group-control]:read-only]:hover:border-input in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:disabled]:bg-input/50 has-[[data-slot=input-group-control]:disabled]:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-[[data-slot=input-group-control]:disabled]:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
				className,
			)}
			{...props}
		/>
	)
}

const inputGroupAddonVariants = cva(
	"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
	{
		variants: {
			align: {
				'inline-start': 'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]',
				'inline-end': 'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]',
				'block-start': 'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
				'block-end': 'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
			},
		},
		defaultVariants: {
			align: 'inline-start',
		},
	},
)

function InputGroupAddon({
	className,
	align = 'inline-start',
	...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
	return (
		<div
			role="group"
			data-slot="input-group-addon"
			data-align={align}
			className={cn(inputGroupAddonVariants({ align }), className)}
			onClick={(e) => {
				if ((e.target as HTMLElement).closest('button')) {
					return
				}
				e.currentTarget.parentElement?.querySelector('input')?.focus()
			}}
			{...props}
		/>
	)
}

const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
	variants: {
		size: {
			xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
			sm: '',
			'icon-xs': 'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
			'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
		},
	},
	defaultVariants: {
		size: 'xs',
	},
})

function InputGroupButton({
	className,
	type = 'button',
	variant = 'ghost',
	size = 'xs',
	...props
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'type'> &
	VariantProps<typeof inputGroupButtonVariants> & {
		type?: 'button' | 'submit' | 'reset'
	}) {
	return (
		<Button
			type={type}
			data-size={size}
			variant={variant}
			className={cn(inputGroupButtonVariants({ size }), className)}
			{...props}
		/>
	)
}

function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
	return (
		<span
			className={cn(
				"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
				className,
			)}
			{...props}
		/>
	)
}

function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
	return (
		<Input
			data-slot="input-group-control"
			className={cn(
				'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 read-only:bg-transparent disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
				className,
			)}
			{...props}
		/>
	)
}

function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
	return (
		<Textarea
			data-slot="input-group-control"
			className={cn(
				'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 read-only:bg-transparent disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
				className,
			)}
			{...props}
		/>
	)
}

export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea }

An input or textarea with addons that share a single border and focus ring. Addons hold icons, text or small buttons and can sit inline at either end of the control or as full-width blocks above and below it.

Features

  • One border and focus ring shared by the control and its addons; hover, focus, disabled and aria-invalid states apply to the whole group
  • Addons at either end of the control (inline-start, inline-end) or as full-width blocks above and below it (block-start, block-end)
  • Addons hold icons, text, buttons, a Kbd shortcut or a Spinner
  • Works with a single-line input (InputGroupInput) or a textarea (InputGroupTextarea)
  • Clicking an addon focuses the control, unless the click landed on a button
  • Small button sizes made for addons (xs, sm, icon-xs, icon-sm)

Usage

import { useState } from 'react'
import { Search, X } from 'lucide-react'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from '@/components/ui/input-group'

export function InputGroupDemo() {
  const [value, setValue] = useState('')

  return (
    <InputGroup className="max-w-sm">
      <InputGroupAddon>
        <Search />
      </InputGroupAddon>
      <InputGroupInput
        placeholder="Search cases..."
        value={value}
        onChange={(event) => setValue(event.target.value)}
      />
      {value && (
        <InputGroupAddon align="inline-end">
          <InputGroupButton size="icon-xs" aria-label="Clear" onClick={() => setValue('')}>
            <X />
          </InputGroupButton>
        </InputGroupAddon>
      )}
    </InputGroup>
  )
}

Examples

Default

A search field for the case register with a result count at the end.

With text

InputGroupText for currencies and units.

With button

A clear button that only appears while the field has a value.

With textarea

A block-end addon below a textarea holds an attach button, a character count and a send button.

Block addon

A block-start addon above a read-only link, with a copy button.

Button sizes

With keyboard shortcut

Loading

A Spinner in an inline-end addon while a value is being checked.

Disabled

Invalid

aria-invalid on the control turns the whole group's border and ring destructive.

Anatomy

import {
  InputGroup,
  InputGroupInput,
  InputGroupTextarea,
  InputGroupAddon,
  InputGroupButton,
  InputGroupText,
} from "@/components/ui/input-group"

<InputGroup>
  <InputGroupAddon>                {/* align="inline-start" (default) */}
    <Search />
  </InputGroupAddon>
  <InputGroupInput placeholder="Search..." />
  <InputGroupAddon align="inline-end">
    <InputGroupButton size="icon-xs"><X /></InputGroupButton>
  </InputGroupAddon>
</InputGroup>

InputGroupInput and InputGroupTextarea render the regular Input and Textarea components with their own border and ring removed.

Props

  • InputGroup — the wrapper (div with role="group").
  • InputGroupInput / InputGroupTextarea — the control. Accept all native input / textarea props.
  • InputGroupAddon — holds icons, InputGroupText, InputGroupButton or Kbd. align: inline-start (default), inline-end, block-start, block-end.
  • InputGroupButton — a Button (defaults to variant="ghost", type="button"). size: xs (default), sm, icon-xs, icon-sm. Accepts all other Button variants.
  • InputGroupText — muted inline text or icon + text.
  • Input
  • Textarea
  • Button Group
  • Kbd