Input Group

Add addons, buttons, and helper content to inputs and textareas.

Basic
With Addons
First Name
20/240 characters

This is a description of the input group.

(optional)
With Buttons
With Tooltip, Dropdown, Popover

This is a description of the input group.

This is a description of the input group.

https://

This is a description of the input group.

https://
.com

This is a description of the input group.

With Kbd
⌘K
⌘K
Ask AI
Tab
Ctrl C

This username is available.

12 results
Disabled

This is a description of the input group.

In Card
Card with Input Group
This is a card with an input group.
https://
0/500 characters
Textarea

This is a description of the input group.

This is a description of the input group.

This is a description of the input group.

Ask, Search or Chat...

This is a description of the input group.

0/280 characters
script.js
Line 1, Column 1 JavaScript

Installation

Install the dependencies

pnpm add cn class-variance-authority github:sveltery/base

Copy the component

Copy the following files into src/lib/components/ui/input-group. It builds on the button, input and textarea components, so copy those as well.

It imports the button, input and textarea components, so copy their folders too.

src/lib/components/ui/input-group/InputGroup.svelte
<script lang="ts">
  import { cn } from 'cn';
  import type { InputGroupProps } from './types.js';
  let { children, class: classProp, ...props }: InputGroupProps = $props();
</script>
<div data-slot="input-group" role="group" {...props} class={cn('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', classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/input-group/InputGroupAddon.svelte
<script lang="ts">
  import { cn } from 'cn';
  import type { InputGroupAddonProps } from './types.js';
  import { inputGroupAddonVariants } from './variants.js';
  let { children, class: classProp, align = 'inline-start', ...props }: InputGroupAddonProps = $props();
</script>
<div
  role="group"
  data-slot="input-group-addon"
  data-align={align}
  onclick={(event) => {
    if ((event.target as HTMLElement).closest('button')) return;
    event.currentTarget.parentElement?.querySelector('input')?.focus();
  }}
  {...props}
  class={cn(inputGroupAddonVariants({ align }), classProp)}
>
  {@render children?.()}
</div>
src/lib/components/ui/input-group/InputGroupButton.svelte
<script lang="ts">
  import { cn } from 'cn';
  import { Button } from '../button/index.js';
  import type { InputGroupButtonProps } from './types.js';
  import { inputGroupButtonVariants } from './variants.js';
  let { children, class: classProp, type = 'button', variant = 'ghost', size = 'xs', ...props }: InputGroupButtonProps = $props();
</script>
<Button {type} data-size={size} {variant} {...props} class={cn(inputGroupButtonVariants({ size }), classProp)}>
  {@render children?.()}
</Button>
src/lib/components/ui/input-group/InputGroupInput.svelte
<script lang="ts">
  import { cn } from 'cn';
  import { Input } from '../input/index.js';
  import type { InputGroupInputProps } from './types.js';
  let { class: classProp, value = $bindable(), ...props }: InputGroupInputProps = $props();
</script>
<Input data-slot="input-group-control" {...props} class={cn('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', classProp)} bind:value />
src/lib/components/ui/input-group/InputGroupText.svelte
<script lang="ts">
  import { cn } from 'cn';
  import type { InputGroupTextProps } from './types.js';
  let { children, class: classProp, ...props }: InputGroupTextProps = $props();
</script>
<span {...props} class={cn("text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none", classProp)}>
  {@render children?.()}
</span>
src/lib/components/ui/input-group/InputGroupTextarea.svelte
<script lang="ts">
  import { cn } from 'cn';
  import { Textarea } from '../textarea/index.js';
  import type { InputGroupTextareaProps } from './types.js';
  let { class: classProp, value = $bindable(), ...props }: InputGroupTextareaProps = $props();
</script>
<Textarea data-slot="input-group-control" {...props} class={cn('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', classProp)} bind:value />
src/lib/components/ui/input-group/types.ts
import type { ComponentProps } from 'svelte';
import type { HTMLAttributes, HTMLButtonAttributes, HTMLInputAttributes } from 'svelte/elements';
import type { TextareaProps } from '../textarea/types.js';
import type { Button } from '../button/index.js';
import type { InputGroupAddonVariants, InputGroupButtonVariants } from './variants.js';
export type InputGroupProps = HTMLAttributes<HTMLDivElement>;
export type InputGroupAddonProps = HTMLAttributes<HTMLDivElement> & InputGroupAddonVariants;
export type InputGroupButtonProps = Omit<ComponentProps<typeof Button>, 'size' | 'type'> & InputGroupButtonVariants & { type?: HTMLButtonAttributes['type'] };
export type InputGroupTextProps = HTMLAttributes<HTMLSpanElement>;
export type InputGroupInputProps = HTMLInputAttributes;
export type InputGroupTextareaProps = TextareaProps;
src/lib/components/ui/input-group/variants.ts
// Adapted from shadcn-ui/ui d75a96ab; MIT: packages/ui/THIRD_PARTY_NOTICES.md.
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
export const inputGroupAddonVariants = cva("text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/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", {
  variants: {
    align: {
      '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',
    },
  },
  defaultVariants: { align: 'inline-start' },
});
export const inputGroupButtonVariants = cva('gap-2 text-sm flex items-center 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' },
});
export type InputGroupAddonVariants = VariantProps<typeof inputGroupAddonVariants>;
export type InputGroupButtonVariants = VariantProps<typeof inputGroupButtonVariants>;
src/lib/components/ui/input-group/index.ts
export { default as InputGroup } from './InputGroup.svelte';
export { default as InputGroupAddon } from './InputGroupAddon.svelte';
export { default as InputGroupButton } from './InputGroupButton.svelte';
export { default as InputGroupText } from './InputGroupText.svelte';
export { default as InputGroupInput } from './InputGroupInput.svelte';
export { default as InputGroupTextarea } from './InputGroupTextarea.svelte';
export { inputGroupAddonVariants, inputGroupButtonVariants } from './variants.js';
export type { InputGroupAddonVariants, InputGroupButtonVariants } from './variants.js';
export type { InputGroupProps, InputGroupAddonProps, InputGroupButtonProps, InputGroupTextProps, InputGroupInputProps, InputGroupTextareaProps } from './types.js';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText } from '$lib/components/ui/input-group';
  let url = $state('');
</script>
<InputGroup>
  <InputGroupAddon>
    <InputGroupText>https://</InputGroupText>
  </InputGroupAddon>
  <InputGroupInput name="url" placeholder="example.com" bind:value={url} />
  <InputGroupAddon align="inline-end">
    <InputGroupButton>Go</InputGroupButton>
  </InputGroupAddon>
</InputGroup>

InputGroupAddon takes align: inline-start (default), inline-end, block-start or block-end. Clicking an addon focuses the input in the group, unless the click lands on a button or your own onclick calls event.preventDefault(). InputGroupInput and InputGroupTextarea accept bind:value like the Input and Textarea they wrap.

API reference

InputGroupInput wraps the Sveltery UI Input, which is styled on top of the Sveltery Base Input primitive. See the Base UI Input documentation for the behavior it ports.