Input Group
Compound input with inline and block addons. Wraps the Input primitive with a pointer-proximity tint, a spring scale-up pulse on valid, and a horizontal shake when aria-invalid flips to true.
Leading icon
<script lang="ts">
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from '$lib/components/ui/input-group';
import { Icon } from '$lib/components/ui/icon';
</script>
<InputGroup>
<InputGroupAddon>
<Icon name="search-line" />
</InputGroupAddon>
<InputGroupInput placeholder="Search…" />
</InputGroup>Trailing button
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { proximity } from '$lib/motion/index.js';
import { animate } from 'motion';
import { prefersReducedMotion } from '$lib/motion/index.js';
let {
ref = $bindable(null),
class: className,
children,
...props
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
// Watch for validity changes on the inner control and spring-animate feedback.
$effect(() => {
if (!ref) return;
const control = ref.querySelector<HTMLElement>('[data-slot=input-group-control]');
if (!control) return;
let prevInvalid = control.getAttribute('aria-invalid') === 'true';
const observer = new MutationObserver(() => {
const nowInvalid = control.getAttribute('aria-invalid') === 'true';
if (nowInvalid === prevInvalid) return;
prevInvalid = nowInvalid;
if (prefersReducedMotion()) return;
if (nowInvalid) {
// Shake once: quick horizontal oscillation
animate(ref!, { x: [0, -6, 6, -4, 4, -2, 2, 0] }, { duration: 0.45, ease: 'easeInOut' });
} else {
// Valid pulse: brief scale up then settle
animate(ref!, { scale: [1, 1.018, 1] }, { duration: 0.4, ease: 'easeOut' });
}
});
observer.observe(control, { attributes: true, attributeFilter: ['aria-invalid'] });
return () => observer.disconnect();
});
</script>
<div
bind:this={ref}
data-slot="input-group"
role="group"
class={cn(
'group/input-group relative flex h-9 w-full min-w-0 items-center rounded-(--radius-control) bg-input/30 transition-colors outline-none hover:bg-input/50 in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-(--radius-panel) has-data-[align=block-start]:rounded-(--radius-panel) has-[[data-slot=input-group-control]:focus-visible]:bg-input/50 has-[[data-slot=input-group-control]:focus-visible]:ring-(length:--ring-width) has-[[data-slot=input-group-control]:focus-visible]:ring-border has-[[data-slot][aria-invalid=true]]:ring-(length:--ring-width) has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[textarea]:rounded-(--radius-control) 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:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 in-data-[slot=button-group]:[&>[data-align=inline-end]]:pr-3 in-data-[slot=button-group]:[&>[data-align=inline-start]]:pl-3 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
)}
use:proximity={{ selector: '[data-proximity-target]', maxDistance: 100 }}
{...props}
>
{@render children?.()}
</div>
Prefix and suffix text
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { proximity } from '$lib/motion/index.js';
import { animate } from 'motion';
import { prefersReducedMotion } from '$lib/motion/index.js';
let {
ref = $bindable(null),
class: className,
children,
...props
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
// Watch for validity changes on the inner control and spring-animate feedback.
$effect(() => {
if (!ref) return;
const control = ref.querySelector<HTMLElement>('[data-slot=input-group-control]');
if (!control) return;
let prevInvalid = control.getAttribute('aria-invalid') === 'true';
const observer = new MutationObserver(() => {
const nowInvalid = control.getAttribute('aria-invalid') === 'true';
if (nowInvalid === prevInvalid) return;
prevInvalid = nowInvalid;
if (prefersReducedMotion()) return;
if (nowInvalid) {
// Shake once: quick horizontal oscillation
animate(ref!, { x: [0, -6, 6, -4, 4, -2, 2, 0] }, { duration: 0.45, ease: 'easeInOut' });
} else {
// Valid pulse: brief scale up then settle
animate(ref!, { scale: [1, 1.018, 1] }, { duration: 0.4, ease: 'easeOut' });
}
});
observer.observe(control, { attributes: true, attributeFilter: ['aria-invalid'] });
return () => observer.disconnect();
});
</script>
<div
bind:this={ref}
data-slot="input-group"
role="group"
class={cn(
'group/input-group relative flex h-9 w-full min-w-0 items-center rounded-(--radius-control) bg-input/30 transition-colors outline-none hover:bg-input/50 in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-(--radius-panel) has-data-[align=block-start]:rounded-(--radius-panel) has-[[data-slot=input-group-control]:focus-visible]:bg-input/50 has-[[data-slot=input-group-control]:focus-visible]:ring-(length:--ring-width) has-[[data-slot=input-group-control]:focus-visible]:ring-border has-[[data-slot][aria-invalid=true]]:ring-(length:--ring-width) has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[textarea]:rounded-(--radius-control) 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:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 in-data-[slot=button-group]:[&>[data-align=inline-end]]:pr-3 in-data-[slot=button-group]:[&>[data-align=inline-start]]:pl-3 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
)}
use:proximity={{ selector: '[data-proximity-target]', maxDistance: 100 }}
{...props}
>
{@render children?.()}
</div>
Block addons
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { proximity } from '$lib/motion/index.js';
import { animate } from 'motion';
import { prefersReducedMotion } from '$lib/motion/index.js';
let {
ref = $bindable(null),
class: className,
children,
...props
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
// Watch for validity changes on the inner control and spring-animate feedback.
$effect(() => {
if (!ref) return;
const control = ref.querySelector<HTMLElement>('[data-slot=input-group-control]');
if (!control) return;
let prevInvalid = control.getAttribute('aria-invalid') === 'true';
const observer = new MutationObserver(() => {
const nowInvalid = control.getAttribute('aria-invalid') === 'true';
if (nowInvalid === prevInvalid) return;
prevInvalid = nowInvalid;
if (prefersReducedMotion()) return;
if (nowInvalid) {
// Shake once: quick horizontal oscillation
animate(ref!, { x: [0, -6, 6, -4, 4, -2, 2, 0] }, { duration: 0.45, ease: 'easeInOut' });
} else {
// Valid pulse: brief scale up then settle
animate(ref!, { scale: [1, 1.018, 1] }, { duration: 0.4, ease: 'easeOut' });
}
});
observer.observe(control, { attributes: true, attributeFilter: ['aria-invalid'] });
return () => observer.disconnect();
});
</script>
<div
bind:this={ref}
data-slot="input-group"
role="group"
class={cn(
'group/input-group relative flex h-9 w-full min-w-0 items-center rounded-(--radius-control) bg-input/30 transition-colors outline-none hover:bg-input/50 in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-(--radius-panel) has-data-[align=block-start]:rounded-(--radius-panel) has-[[data-slot=input-group-control]:focus-visible]:bg-input/50 has-[[data-slot=input-group-control]:focus-visible]:ring-(length:--ring-width) has-[[data-slot=input-group-control]:focus-visible]:ring-border has-[[data-slot][aria-invalid=true]]:ring-(length:--ring-width) has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[textarea]:rounded-(--radius-control) 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:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 in-data-[slot=button-group]:[&>[data-align=inline-end]]:pr-3 in-data-[slot=button-group]:[&>[data-align=inline-start]]:pl-3 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
)}
use:proximity={{ selector: '[data-proximity-target]', maxDistance: 100 }}
{...props}
>
{@render children?.()}
</div>
Invalid — shake on error
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { proximity } from '$lib/motion/index.js';
import { animate } from 'motion';
import { prefersReducedMotion } from '$lib/motion/index.js';
let {
ref = $bindable(null),
class: className,
children,
...props
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
// Watch for validity changes on the inner control and spring-animate feedback.
$effect(() => {
if (!ref) return;
const control = ref.querySelector<HTMLElement>('[data-slot=input-group-control]');
if (!control) return;
let prevInvalid = control.getAttribute('aria-invalid') === 'true';
const observer = new MutationObserver(() => {
const nowInvalid = control.getAttribute('aria-invalid') === 'true';
if (nowInvalid === prevInvalid) return;
prevInvalid = nowInvalid;
if (prefersReducedMotion()) return;
if (nowInvalid) {
// Shake once: quick horizontal oscillation
animate(ref!, { x: [0, -6, 6, -4, 4, -2, 2, 0] }, { duration: 0.45, ease: 'easeInOut' });
} else {
// Valid pulse: brief scale up then settle
animate(ref!, { scale: [1, 1.018, 1] }, { duration: 0.4, ease: 'easeOut' });
}
});
observer.observe(control, { attributes: true, attributeFilter: ['aria-invalid'] });
return () => observer.disconnect();
});
</script>
<div
bind:this={ref}
data-slot="input-group"
role="group"
class={cn(
'group/input-group relative flex h-9 w-full min-w-0 items-center rounded-(--radius-control) bg-input/30 transition-colors outline-none hover:bg-input/50 in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-(--radius-panel) has-data-[align=block-start]:rounded-(--radius-panel) has-[[data-slot=input-group-control]:focus-visible]:bg-input/50 has-[[data-slot=input-group-control]:focus-visible]:ring-(length:--ring-width) has-[[data-slot=input-group-control]:focus-visible]:ring-border has-[[data-slot][aria-invalid=true]]:ring-(length:--ring-width) has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[textarea]:rounded-(--radius-control) 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:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 in-data-[slot=button-group]:[&>[data-align=inline-end]]:pr-3 in-data-[slot=button-group]:[&>[data-align=inline-start]]:pl-3 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
)}
use:proximity={{ selector: '[data-proximity-target]', maxDistance: 100 }}
{...props}
>
{@render children?.()}
</div>
Tooltip
Wrap an icon addon in a Tooltip to surface contextual help without cluttering the label.
<script lang="ts">
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from '$lib/components/ui/input-group';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '$lib/components/ui/tooltip';
import { Icon } from '$lib/components/ui/icon';
</script>
<TooltipProvider>
<InputGroup>
<InputGroupInput placeholder="API key" />
<InputGroupAddon align="inline-end">
<Tooltip>
<TooltipTrigger class="flex items-center px-2 text-muted-foreground hover:text-foreground">
<Icon name="question-line" class="size-4" size="1rem" />
</TooltipTrigger>
<TooltipContent>Find your key in Settings → API.</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
</TooltipProvider>Textarea
Swap the inner control for a Textarea — block addons reflow above and below automatically.
<script lang="ts">
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupTextarea,
InputGroupButton,
InputGroupText
} from '$lib/components/ui/input-group';
import { Icon } from '$lib/components/ui/icon';
</script>
<InputGroup>
<InputGroupAddon align="block-start">
<InputGroupText>Message</InputGroupText>
</InputGroupAddon>
<InputGroupTextarea placeholder="Write something…" rows={3} />
<InputGroupAddon align="block-end">
<InputGroupButton size="xs" variant="ghost" class="ml-auto">
<Icon name="eye-line" />
Preview
</InputGroupButton>
</InputGroupAddon>
</InputGroup>Spinner
An inline Spinner addon communicates async validation without a full loading overlay.
<script lang="ts">
import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from '$lib/components/ui/input-group';
import { Spinner } from '$lib/components/ui/spinner';
</script>
<InputGroup>
<InputGroupInput placeholder="Checking availability…" />
<InputGroupAddon align="inline-end">
<Spinner class="mx-2 size-4 text-muted-foreground" />
</InputGroupAddon>
</InputGroup>Label addon
A Label inside an addon acts as a prefix descriptor — useful for unit inputs or tagged fields.
<script lang="ts">
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText
} from '$lib/components/ui/input-group';
import { Label } from '$lib/components/ui/label';
</script>
<InputGroup>
<InputGroupAddon>
<Label class="px-3 text-muted-foreground">Qty</Label>
</InputGroupAddon>
<InputGroupInput type="number" placeholder="0" class="text-right" />
<InputGroupAddon align="inline-end">
<InputGroupText>units</InputGroupText>
</InputGroupAddon>
</InputGroup>Dropdown
Pair a DropdownMenu trigger with an Input for patterns like country-code + phone number.
<script lang="ts">
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupButton
} from '$lib/components/ui/input-group';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem
} from '$lib/components/ui/dropdown-menu';
import { Icon } from '$lib/components/ui/icon';
let selectedCountry = $state('US +1');
</script>
<InputGroup>
<InputGroupAddon>
<DropdownMenu>
<DropdownMenuTrigger>
{#snippet child({ props })}
<InputGroupButton size="sm" variant="ghost" {...props}>
{selectedCountry}
<Icon name="arrow-down-s-line" class="size-3 opacity-60" size="0.75rem" />
</InputGroupButton>
{/snippet}
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onclick={() => (selectedCountry = 'US +1')}>US +1</DropdownMenuItem>
<DropdownMenuItem onclick={() => (selectedCountry = 'GB +44')}>GB +44</DropdownMenuItem>
<DropdownMenuItem onclick={() => (selectedCountry = 'DE +49')}>DE +49</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</InputGroupAddon>
<InputGroupInput type="tel" placeholder="555 000 0000" />
</InputGroup>InputGroup props
Root accepts native div attributes. A MutationObserver watches the inner control's aria-invalid — flip it to trigger the shake; flip back for the spring-scale valid pulse.
| Prop | Type | Default | Description |
|---|---|---|---|
string | — | Merged onto the root via tailwind-merge. | |
HTMLDivElement | null | null | Two-way-bindable element reference. |
InputGroupAddon props
| Prop | Type | Default | Description |
|---|---|---|---|
'inline-start' | 'inline-end' | 'block-start' | 'block-end' | 'inline-start' | Placement. Inline addons sit left or right of the control; block addons stack above or below it and the group reshapes to match. | |
string | — | Merged onto the addon via tailwind-merge. |
InputGroupButton props
Wraps Button with input-friendly sizing. Unlike the top-level Button, `href` is disabled — keep navigation outside the group.
| Prop | Type | Default | Description |
|---|---|---|---|
'xs' | 'sm' | 'icon-xs' | 'icon-sm' | 'xs' | Input-scale sizing only. Icon sizes render as a square. | |
ButtonVariant | 'ghost' | Inherits Button variants. Ghost is the quiet default inside a group. |