Shared bordered box with text/icon add-ons around a connected input, built on the @foldkit/ui Input helper.

Preview
Style
Basic
12 results
With Addons
With Buttons
With Kbd
⌘K
⌘K
Ask AI
Tab
12 results
In Card
Card with Input Group
This is a card with an input group.
https://
Textarea
Ask, Search or Chat...
0/280 characters
src/demo/views/input-group.ts382 lines
import type { Html, HtmlBuilder } from 'foldkit/html'

import {
  inputGroup,
  inputGroupAddon,
  inputGroupButton,
  inputGroupInput,
  inputGroupText,
  inputGroupTextarea,
} from '../../generated/registry/ui/input-group'
import { Kbd } from '../../generated/registry/ui/kbd'
import { field, fieldGroup, fieldLabel } from '../../generated/registry/ui/fieldset'
import { button } from '../../generated/registry/ui/button'
import { icon } from '../../generated/registry/lib/icons'
import { Search, Info, Star, Mail, ExternalLink, Mic, Radio } from 'lucide'

import { defineSlice } from '../slice'
import type { Model, Message } from '../assemble'

export const inputGroupView = (_model: Model, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          h.div(
            [h.Class('flex w-full max-w-sm flex-col gap-4')],
            [
              inputGroup(
                { className: 'max-w-sm' },
                [
                  inputGroupInput({ id: 'input-group-basic', placeholder: 'Search…' }, h),
                  inputGroupAddon({}, [icon(h, Search, 'size-4')], h),
                  inputGroupAddon({ align: 'inline-end' }, ['12 results'], h),
                ],
                h,
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Addons']),
          fieldGroup<Message>(
            {},
            [
              field<Message>(
                {},
                [
                  fieldLabel<Message>({ for: 'input-icon-left' }, ['Addon (inline-start)'], h),
                  inputGroup(
                    {},
                    [
                      inputGroupInput({ id: 'input-icon-left', placeholder: '' }, h),
                      inputGroupAddon({}, [icon(h, Search, 'size-4 text-muted-foreground')], h),
                    ],
                    h,
                  ),
                ],
                h,
              ),
              field<Message>(
                {},
                [
                  fieldLabel<Message>({ for: 'input-icon-right' }, ['Addon (inline-end)'], h),
                  inputGroup(
                    {},
                    [
                      inputGroupInput({ id: 'input-icon-right', placeholder: '' }, h),
                      inputGroupAddon({ align: 'inline-end' }, [icon(h, Info, 'size-4')], h),
                    ],
                    h,
                  ),
                ],
                h,
              ),
              field<Message>(
                {},
                [
                  fieldLabel<Message>({ for: 'input-icon-both' }, ['Addon (both)'], h),
                  inputGroup(
                    {},
                    [
                      inputGroupInput({ id: 'input-icon-both', placeholder: '' }, h),
                      inputGroupAddon({}, [icon(h, Mic, 'size-4 text-muted-foreground')], h),
                      inputGroupAddon(
                        { align: 'inline-end' },
                        [icon(h, Radio, 'size-4 animate-pulse text-red-500')],
                        h,
                      ),
                    ],
                    h,
                  ),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Buttons']),
          fieldGroup<Message>(
            {},
            [
              field<Message>(
                {},
                [
                  fieldLabel<Message>({ for: 'input-button-default' }, ['Button (default)'], h),
                  inputGroup(
                    {},
                    [
                      inputGroupInput({ id: 'input-button-default', placeholder: '' }, h),
                      inputGroupAddon({}, [inputGroupButton<Message>({}, 'Default', h)], h),
                    ],
                    h,
                  ),
                ],
                h,
              ),
              field<Message>(
                {},
                [
                  fieldLabel<Message>({ for: 'input-button-outline' }, ['Button (outline)'], h),
                  inputGroup(
                    {},
                    [
                      inputGroupInput({ id: 'input-button-outline', placeholder: '' }, h),
                      inputGroupAddon(
                        {},
                        [inputGroupButton<Message>({ variant: 'outline' }, 'Outline', h)],
                        h,
                      ),
                    ],
                    h,
                  ),
                ],
                h,
              ),
              field<Message>(
                {},
                [
                  fieldLabel<Message>({ for: 'input-button-icon' }, ['Button (icon)'], h),
                  inputGroup(
                    {},
                    [
                      inputGroupInput({ id: 'input-button-icon', placeholder: '' }, h),
                      inputGroupAddon(
                        { align: 'inline-end' },
                        [
                          inputGroupButton<Message>(
                            { size: 'icon-xs' },
                            icon(h, Star, 'size-4'),
                            h,
                          ),
                        ],
                        h,
                      ),
                    ],
                    h,
                  ),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Kbd']),
          fieldGroup<Message>(
            {},
            [
              field<Message>(
                {},
                [
                  fieldLabel<Message>({ for: 'input-kbd' }, ['Input Group with Kbd'], h),
                  inputGroup(
                    {},
                    [
                      inputGroupInput({ id: 'input-kbd', placeholder: '' }, h),
                      inputGroupAddon({}, [Kbd<Message>({}, ['⌘K'], h)], h),
                    ],
                    h,
                  ),
                  inputGroup(
                    {},
                    [
                      inputGroupInput({ id: 'input-kbd-2', placeholder: '' }, h),
                      inputGroupAddon({ align: 'inline-end' }, [Kbd<Message>({}, ['⌘K'], h)], h),
                    ],
                    h,
                  ),
                ],
                h,
              ),
              inputGroup(
                {},
                [
                  inputGroupInput(
                    { id: 'input-search-apps', placeholder: 'Search for Apps...' },
                    h,
                  ),
                  inputGroupAddon({ align: 'inline-end' }, ['Ask AI'], h),
                  inputGroupAddon({ align: 'inline-end' }, [Kbd<Message>({}, ['Tab'], h)], h),
                ],
                h,
              ),
              inputGroup(
                {},
                [
                  inputGroupInput(
                    { id: 'input-search-docs', placeholder: 'Search documentation...' },
                    h,
                  ),
                  inputGroupAddon({}, [icon(h, Search, 'size-4')], h),
                  inputGroupAddon({ align: 'inline-end' }, ['12 results'], h),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['In Card']),
          h.div(
            [h.Class('w-full max-w-md rounded-xl border bg-card p-6')],
            [
              h.div([h.Class('mb-4 font-semibold')], ['Card with Input Group']),
              h.div(
                [h.Class('mb-4 text-sm text-muted-foreground')],
                ['This is a card with an input group.'],
              ),
              fieldGroup<Message>(
                {},
                [
                  field<Message>(
                    {},
                    [
                      fieldLabel<Message>({ for: 'email-input' }, ['Email Address'], h),
                      inputGroup(
                        {},
                        [
                          inputGroupInput(
                            { id: 'email-input', placeholder: 'you@example.com', type: 'email' },
                            h,
                          ),
                          inputGroupAddon({ align: 'inline-end' }, [icon(h, Mail, 'size-4')], h),
                        ],
                        h,
                      ),
                    ],
                    h,
                  ),
                  field<Message>(
                    {},
                    [
                      fieldLabel<Message>({ for: 'website-input' }, ['Website URL'], h),
                      inputGroup(
                        {},
                        [
                          inputGroupAddon({}, [inputGroupText({}, ['https://'], h)], h),
                          inputGroupInput({ id: 'website-input', placeholder: 'example.com' }, h),
                          inputGroupAddon(
                            { align: 'inline-end' },
                            [icon(h, ExternalLink, 'size-4')],
                            h,
                          ),
                        ],
                        h,
                      ),
                    ],
                    h,
                  ),
                ],
                h,
              ),
              h.div(
                [h.Class('mt-4 flex justify-end gap-2')],
                [
                  button<Message>({ variant: 'outline' }, 'Cancel', h),
                  button<Message>({}, 'Submit', h),
                ],
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Textarea']),
          fieldGroup<Message>(
            {},
            [
              field<Message>(
                {},
                [
                  fieldLabel<Message>({ for: 'prompt-31' }, ['Addon (block-start)'], h),
                  inputGroup(
                    {},
                    [
                      inputGroupTextarea(
                        { id: 'prompt-31', placeholder: 'Ask, Search or Chat...' },
                        h,
                      ),
                      inputGroupAddon(
                        { align: 'block-start' },
                        [
                          inputGroupText({}, ['Ask, Search or Chat...'], h),
                          icon(h, Info, 'size-4 ml-auto text-muted-foreground'),
                        ],
                        h,
                      ),
                    ],
                    h,
                  ),
                ],
                h,
              ),
              field<Message>(
                {},
                [
                  fieldLabel<Message>({ for: 'textarea-block-end' }, ['Addon (block-end)'], h),
                  inputGroup(
                    {},
                    [
                      inputGroupTextarea(
                        { id: 'textarea-block-end', placeholder: 'Enter your text here...' },
                        h,
                      ),
                      inputGroupAddon(
                        { align: 'block-end' },
                        [
                          inputGroupText({}, ['0/280 characters'], h),
                          inputGroupButton<Message>(
                            {
                              size: 'icon-xs',
                              className: 'ml-auto rounded-full',
                              variant: 'default',
                            },
                            icon(h, Star, 'size-4'),
                            h,
                          ),
                        ],
                        h,
                      ),
                    ],
                    h,
                  ),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
    ],
  )

export const slice = defineSlice({
  fields: {},
  init: {},
  messages: [],
  handlers: () => ({}),
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/input-group

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/input-group.ts200 lines
import type { Attribute, Html, HtmlBuilder } from 'foldkit/html'

import { cn } from '@/lib/utils'
import { button, type ButtonConfig } from './button'
import { inputClass } from './input'
import { textareaClass } from './textarea'

type Child = Html | string

// InputGroup draws a shared bordered box and lets you slot text/icon add-ons
// around a connected input. The inner control carries
// data-slot="input-group-control" so the group token's focus/invalid/disabled
// frame states key off it.

/** Upstream InputGroup root string. */
export const inputGroupClass =
  'border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-lg border transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 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-[>[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 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto'

/** Upstream InputGroupAddon base string; alignment via the align tokens. */
export const inputGroupAddonClass =
  'text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*=\'size-\'])]:size-4 flex cursor-text items-center justify-center select-none'

export const inputGroupAddonAlignClasses = {
  'inline-start': 'pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first',
  'inline-end': 'pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last',
  'block-start': 'px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start',
  'block-end': 'px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start',
} as const

export type InputGroupAddonAlign = keyof typeof inputGroupAddonAlignClasses

export const inputGroupButtonSizeKeys = ['xs', 'sm', 'icon-xs', 'icon-sm'] as const
export type InputGroupButtonSize = (typeof inputGroupButtonSizeKeys)[number]

/** Upstream InputGroupButton base string (sizes are tokens). */
export const inputGroupButtonClass = 'gap-2 text-sm flex items-center shadow-none'

/** Upstream InputGroupButton size tokens; keyed like upstream's cva variants. */
export const inputGroupButtonSizeClasses = {
  xs: 'h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*=\'size-\'])]:size-3.5',
  sm: 'gap-1',
  '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',
} as const

export type InputGroupTextareaConfig<M> = Readonly<{
  id: string
  value?: string
  onInput?: (value: string) => M
  isDisabled?: boolean
  isReadOnly?: boolean
  isInvalid?: boolean
  placeholder?: string
  name?: string
  rows?: number
  className?: string
}>

export const inputGroupTextareaClass = 'rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none'

export const inputGroupTextClass =
  'text-muted-foreground gap-2 text-sm [&_svg:not([class*=\'size-\'])]:size-4 flex items-center [&_svg]:pointer-events-none'

export const inputGroupInputClass = 'rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1'

/** The connected textarea for use inside `inputGroup`. Emits
 *  data-slot="input-group-control" so the group frame reacts to it. */
export const inputGroupTextarea = <M>(
  config: InputGroupTextareaConfig<M>,
  h: HtmlBuilder<M>,
): Html =>
  h.textarea([
    h.Id(config.id),
    ...(config.onInput === undefined ? [] : [h.OnInput(config.onInput)]),
    ...(config.value === undefined ? [] : [h.Value(config.value)]),
    ...(config.isDisabled === true ? [h.Disabled(true), h.DataAttribute('disabled', '')] : []),
    ...(config.isReadOnly === true ? [h.Attribute('readonly', 'true')] : []),
    ...(config.isInvalid === true ? [h.AriaInvalid(true), h.DataAttribute('invalid', '')] : []),
    ...(config.name === undefined ? [] : [h.Name(config.name)]),
    ...(config.rows === undefined ? [] : [h.Rows(config.rows)]),
    ...(config.placeholder === undefined ? [] : [h.Placeholder(config.placeholder)]),
    h.DataAttribute('slot', 'input-group-control'),
    h.Class(cn(textareaClass, inputGroupTextareaClass, config.className)),
  ])

export type InputGroupInputConfig<M> = Readonly<{
  id: string
  ariaLabel?: string
  value?: string
  onInput?: (value: string) => M
  isDisabled?: boolean
  isReadOnly?: boolean
  isInvalid?: boolean
  placeholder?: string
  name?: string
  type?: string
  className?: string
  /** Extra attributes merged onto the input element. */
  attributes?: ReadonlyArray<Attribute<M>>
}>

/** The connected input for use inside `inputGroup`. Emits
 *  data-slot="input-group-control" so the group frame reacts to it. */
export const inputGroupInput = <M>(config: InputGroupInputConfig<M>, h: HtmlBuilder<M>): Html =>
  h.input([
    h.Id(config.id),
    ...(config.ariaLabel === undefined ? [] : [h.AriaLabel(config.ariaLabel)]),
    ...(config.onInput === undefined ? [] : [h.OnInput(config.onInput)]),
    ...(config.value === undefined ? [] : [h.Value(config.value)]),
    ...(config.isDisabled === true ? [h.Disabled(true), h.DataAttribute('disabled', '')] : []),
    ...(config.isReadOnly === true ? [h.Attribute('readonly', 'true')] : []),
    ...(config.isInvalid === true ? [h.AriaInvalid(true), h.DataAttribute('invalid', '')] : []),
    ...(config.name === undefined ? [] : [h.Name(config.name)]),
    ...(config.type === undefined ? [] : [h.Type(config.type)]),
    ...(config.placeholder === undefined ? [] : [h.Placeholder(config.placeholder)]),
    h.DataAttribute('slot', 'input-group-control'),
    h.Class(cn(inputClass, inputGroupInputClass, config.className)),
    ...(config.attributes ?? []),
  ])

export type InputGroupButtonConfig<M> = Omit<ButtonConfig<M>, 'size'> &
  Readonly<{
    /** Group-local size — keys `cn-input-group-button-size-*` tokens. */
    size?: InputGroupButtonSize
  }>

/** A `button` styled to sit inside an `inputGroup` — ghost by default,
 *  sized by the group tokens (upstream keeps the underlying Button on its own
 *  default size and layers the group size token over it). */
export const inputGroupButton = <M>(
  config: InputGroupButtonConfig<M>,
  label: Html | string,
  h: HtmlBuilder<M>,
): Html => {
  const size = config.size ?? 'xs'
  const { size: _groupSize, ...buttonConfig } = config
  return button<M>(
    {
      ...buttonConfig,
      variant: config.variant ?? 'ghost',
      className: cn(inputGroupButtonClass, inputGroupButtonSizeClasses[size], config.className),
      attributes: [h.DataAttribute('size', size), ...(config.attributes ?? [])],
    },
    label,
    h,
  )
}

type StyleConfig = Readonly<{ className?: string }>

type AddonConfig = StyleConfig & Readonly<{ align?: InputGroupAddonAlign }>

/** Add-on (text or icon) for either side of the group. Clicking the addon
 *  focuses the contained input (unless the click target is a button), matching
 *  upstream InputGroupAddon's onClick delegation. */
export const inputGroupAddon = <M>(
  config: AddonConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html => {
  const align = config.align ?? 'inline-start'
  return h.div(
    [
      h.Role('group'),
      h.DataAttribute('slot', 'input-group-addon'),
      h.DataAttribute('align', align),
      h.Class(cn(inputGroupAddonClass, inputGroupAddonAlignClasses[align], config.className)),
      h.Attribute(
        'onclick',
        "if(!event.target.closest('button'))this.parentElement?.querySelector('input')?.focus()",
      ),
    ],
    children,
  )
}

/** Alias kept for backward compatibility — an inline-start text addon.
 *  Upstream renders a `span` with NO data-slot (foldcn previously added
 *  an extra slot; removed to match upstream). */
export const inputGroupText = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html => h.span([h.Class(cn(inputGroupTextClass, config.className))], children)

/** Segmented container — pass addons / controls as children. */
export const inputGroup = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Role('group'),
      h.Class(cn(inputGroupClass, config.className)),
      h.DataAttribute('slot', 'input-group'),
    ],
    children,
  )