Input groups with addons and embedded controls
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
...import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
export default function InputGroupDemoExample() {
return (
<InputGroup class="max-w-xs">
<InputGroupInput placeholder="Search..." />
<InputGroupAddon>
<span aria-hidden="true">•</span>
</InputGroupAddon>
<InputGroupAddon align="inline-end">12 results</InputGroupAddon>
</InputGroup>
)
}
Installation
npx @fictjs/shadcn@latest add input-groupCopy the Fict shadcn component into your project.
Source
components/ui/input-group.tsx
src/components/ui/input-group.tsx
import { cn } from '@/lib/cn'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
type DivProps = JSX.IntrinsicElements['div']
type SpanProps = JSX.IntrinsicElements['span']
type AddonProps = SpanProps & { align?: 'inline-start' | 'inline-end' | 'block-start' | 'block-end' }
type InputGroupInputProps = {
class?: string
[key: string]: unknown
}
export function InputGroup(props: DivProps) {
const { class: className, ...rest } = props
return <div class={cn('flex w-full items-stretch rounded-md border border-input bg-background', className)} {...rest} />
}
export function InputGroupAddon(props: AddonProps) {
const { class: className, align = 'inline-start', ...rest } = props
return <span data-slot='input-group-addon' data-align={align} class={cn('inline-flex items-center gap-2 px-2 text-sm text-muted-foreground', align === 'inline-end' && 'order-last', align.startsWith('block-') && 'w-full', className)} {...rest} />
}
export function InputGroupInput(props: InputGroupInputProps) {
const { class: className, ...rest } = props
return <Input class={cn('rounded-none border-0 shadow-none focus-visible:ring-0', className)} {...rest} />
}
export function InputGroupButton(props: InputGroupInputProps) {
const { class: className, ...rest } = props
return <Button type='button' variant='ghost' size='xs' class={cn('shadow-none', className)} {...rest} />
}
export function InputGroupText(props: SpanProps) {
const { class: className, ...rest } = props
return <span class={cn('flex items-center gap-2 text-sm text-muted-foreground', className)} {...rest} />
}
export function InputGroupTextarea(props: InputGroupInputProps) {
const { class: className, ...rest } = props
return <Textarea data-slot='input-group-control' class={cn('flex-1 resize-none rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0', className)} {...rest} />
}
Usage
Examples
Inline Start
Icon positioned at the start.
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
...import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
export default function InputGroupInlineStartExample() {
return (
<Field class="max-w-sm">
<FieldLabel for="inline-start-input">Input</FieldLabel>
<InputGroup>
<InputGroupInput id="inline-start-input" placeholder="Search..." />
<InputGroupAddon align="inline-start">
<span aria-hidden="true">•</span>
</InputGroupAddon>
</InputGroup>
<FieldDescription>Icon positioned at the start.</FieldDescription>
</Field>
)
}
Inline End
Icon positioned at the end.
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
...import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
export default function InputGroupInlineEndExample() {
return (
<Field class="max-w-sm">
<FieldLabel for="inline-end-input">Input</FieldLabel>
<InputGroup>
<InputGroupInput id="inline-end-input" type="password" placeholder="Enter password" />
<InputGroupAddon align="inline-end">
<span aria-hidden="true">•</span>
</InputGroupAddon>
</InputGroup>
<FieldDescription>Icon positioned at the end.</FieldDescription>
</Field>
)
}
Block Start
Full Name
Header positioned above the input.
script.js
Header positioned above the textarea.
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
import {
...import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from '@/components/ui/input-group'
export default function InputGroupBlockStartExample() {
return (
<FieldGroup class="max-w-sm">
<Field>
<FieldLabel for="block-start-input">Input</FieldLabel>
<InputGroup class="h-auto">
<InputGroupInput id="block-start-input" placeholder="Enter your name" />
<InputGroupAddon align="block-start">
<InputGroupText>Full Name</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FieldDescription>Header positioned above the input.</FieldDescription>
</Field>
<Field>
<FieldLabel for="block-start-textarea">Textarea</FieldLabel>
<InputGroup>
<InputGroupTextarea
id="block-start-textarea"
placeholder="console.log('Hello, world!');"
class="font-mono text-sm"
/>
<InputGroupAddon align="block-start">
<span aria-hidden="true">•</span>
<InputGroupText class="font-mono">script.js</InputGroupText>
<InputGroupButton size="icon-xs" class="ml-auto">
<span aria-hidden="true">•</span>
<span class="sr-only">Copy</span>
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<FieldDescription>Header positioned above the textarea.</FieldDescription>
</Field>
</FieldGroup>
)
}
Block End
USD
Footer positioned below the input.
0/280
Footer positioned below the textarea.
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
import {
...import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from '@/components/ui/input-group'
export default function InputGroupBlockEndExample() {
return (
<FieldGroup class="max-w-sm">
<Field>
<FieldLabel for="block-end-input">Input</FieldLabel>
<InputGroup class="h-auto">
<InputGroupInput id="block-end-input" placeholder="Enter amount" />
<InputGroupAddon align="block-end">
<InputGroupText>USD</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FieldDescription>Footer positioned below the input.</FieldDescription>
</Field>
<Field>
<FieldLabel for="block-end-textarea">Textarea</FieldLabel>
<InputGroup>
<InputGroupTextarea id="block-end-textarea" placeholder="Write a comment..." />
<InputGroupAddon align="block-end">
<InputGroupText>0/280</InputGroupText>
<InputGroupButton variant="default" size="sm" class="ml-auto">
Post
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<FieldDescription>Footer positioned below the textarea.</FieldDescription>
</Field>
</FieldGroup>
)
}
Icon
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
...import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
export default function InputGroupIconExample() {
return (
<div class="grid w-full max-w-sm gap-6">
<InputGroup>
<InputGroupInput placeholder="Search..." />
<InputGroupAddon>
<span aria-hidden="true">•</span>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput type="email" placeholder="Enter your email" />
<InputGroupAddon>
<span aria-hidden="true">•</span>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder="Card number" />
<InputGroupAddon>
<span aria-hidden="true">•</span>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<span aria-hidden="true">•</span>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder="Card number" />
<InputGroupAddon align="inline-end">
<span aria-hidden="true">•</span>
<span aria-hidden="true">•</span>
</InputGroupAddon>
</InputGroup>
</div>
)
}
Text
$USD
https://.com
@company.com
120 characters left
import {
InputGroup,
...import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from '@/components/ui/input-group'
export default function InputGroupTextExample() {
return (
<div class="grid w-full max-w-sm gap-6">
<InputGroup>
<InputGroupAddon>
<InputGroupText>$</InputGroupText>
</InputGroupAddon>
<InputGroupInput placeholder="0.00" />
<InputGroupAddon align="inline-end">
<InputGroupText>USD</InputGroupText>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupAddon>
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput placeholder="example.com" class="pl-0.5!" />
<InputGroupAddon align="inline-end">
<InputGroupText>.com</InputGroupText>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder="Enter your username" />
<InputGroupAddon align="inline-end">
<InputGroupText>@company.com</InputGroupText>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupTextarea placeholder="Enter your message" />
<InputGroupAddon align="block-end">
<InputGroupText class="text-xs text-muted-foreground">120 characters left</InputGroupText>
</InputGroupAddon>
</InputGroup>
</div>
)
}
Button
https://
Your connection is not secure.
You should not enter any sensitive information on this site.
import {
InputGroup,
...import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@/components/ui/input-group'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
export default function InputGroupButtonExample() {
let copied = $state(false)
let favorite = $state(false)
return (
<div class="grid gap-4">
<InputGroup>
<InputGroupInput defaultValue="https://x.com/shadcn" />
<InputGroupAddon align="inline-end">
<InputGroupButton
aria-label="Copy"
onClick={() => {
copied = true
}}
>
{copied ? 'Copied' : 'Copy'}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupAddon><Popover><PopoverTrigger asChild><InputGroupButton aria-label="Info">i</InputGroupButton></PopoverTrigger><PopoverContent><strong>Your connection is not secure.</strong><p>You should not enter any sensitive information on this site.</p></PopoverContent></Popover></InputGroupAddon>
<InputGroupAddon>https://</InputGroupAddon>
<InputGroupInput />
<InputGroupAddon align="inline-end">
<InputGroupButton
aria-label="Favorite"
aria-pressed={favorite}
onClick={() => {
favorite = !favorite
}}
>
{favorite ? '★' : '☆'}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder="Type to search..." />
<InputGroupAddon align="inline-end">
<InputGroupButton>Search</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
)
}
Kbd
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
import { Kbd } from '@/components/ui/kbd'
...import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
import { Kbd } from '@/components/ui/kbd'
export default function InputGroupKbdExample() {
return (
<InputGroup class="max-w-sm">
<InputGroupInput placeholder="Search..." />
<InputGroupAddon>
<span aria-hidden="true">•</span>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<Kbd>⌘K</Kbd>
</InputGroupAddon>
</InputGroup>
)
}
Dropdown
import {
DropdownMenu,
...import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@/components/ui/input-group'
export default function InputGroupDropdownExample() {
return (
<div class="grid w-full max-w-sm gap-4">
<InputGroup>
<InputGroupInput placeholder="Enter file name" />
<InputGroupAddon align="inline-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<InputGroupButton variant="ghost" aria-label="More" size="icon-xs">
<span aria-hidden="true">•</span>
</InputGroupButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Copy path</DropdownMenuItem>
<DropdownMenuItem>Open location</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</InputGroupAddon>
</InputGroup>
<InputGroup class="[--radius:1rem]">
<InputGroupInput placeholder="Enter search query" />
<InputGroupAddon align="inline-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<InputGroupButton variant="ghost" class="pr-1.5! text-xs">
Search In... <span aria-hidden="true">•</span>
</InputGroupButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="[--radius:0.95rem]">
<DropdownMenuItem>Documentation</DropdownMenuItem>
<DropdownMenuItem>Blog Posts</DropdownMenuItem>
<DropdownMenuItem>Changelog</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</InputGroupAddon>
</InputGroup>
</div>
)
}
Spinner
Saving...
Please wait...
import {
InputGroup,
...import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from '@/components/ui/input-group'
import { Spinner } from '@/components/ui/spinner'
export default function InputGroupSpinnerExample() {
return (
<div class="grid w-full max-w-sm gap-4">
<InputGroup>
<InputGroupInput placeholder="Searching..." />
<InputGroupAddon align="inline-end">
<Spinner />
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder="Processing..." />
<InputGroupAddon>
<Spinner />
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder="Saving changes..." />
<InputGroupAddon align="inline-end">
<InputGroupText>Saving...</InputGroupText>
<Spinner />
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder="Refreshing data..." />
<InputGroupAddon>
<span aria-hidden="true">•</span>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupText class="text-muted-foreground">Please wait...</InputGroupText>
</InputGroupAddon>
</InputGroup>
</div>
)
}
Textarea
Line 1, Column 1script.js
import {
InputGroup,
...import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupTextarea,
} from '@/components/ui/input-group'
export default function InputGroupTextareaExample() {
return (
<div class="grid w-full max-w-md gap-4">
<InputGroup>
<InputGroupTextarea
id="textarea-code-32"
placeholder="console.log('Hello, world!');"
class="min-h-[200px]"
/>
<InputGroupAddon align="block-end" class="border-t">
<InputGroupText>Line 1, Column 1</InputGroupText>
<InputGroupButton size="sm" class="ml-auto" variant="default">
Run <span aria-hidden="true">↵</span>
</InputGroupButton>
</InputGroupAddon>
<InputGroupAddon align="block-start" class="border-b">
<InputGroupText class="font-mono font-medium">
<span aria-hidden="true">JS</span>
script.js
</InputGroupText>
<InputGroupButton class="ml-auto" size="icon-xs">
<span aria-hidden="true">↻</span>
</InputGroupButton>
<InputGroupButton variant="ghost" size="icon-xs">
<span aria-hidden="true">□</span>
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
)
}
Custom
import {
InputGroup,
...import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from '@/components/ui/input-group'
export default function InputGroupCustomExample() {
return (
<InputGroup>
<InputGroupTextarea class="min-h-24" placeholder="Autoresize textarea..." />
<InputGroupAddon align="block-end">
<InputGroupButton class="ml-auto" variant="default">
Submit
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
)
}
Rtl
import {
InputGroup,
...import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupTextarea,
} from '@/components/ui/input-group'
import { Spinner } from '@/components/ui/spinner'
const translations = {
ar: {
dir: 'rtl',
search: 'بحث...',
results: '١٢ نتيجة',
searching: 'جاري البحث...',
saving: 'جاري حفظ التغييرات...',
savingStatus: 'جاري الحفظ...',
textarea: 'منطقة النص',
comment: 'اكتب تعليقًا...',
post: 'نشر',
footer: 'تذييل موضع أسفل منطقة النص.',
},
he: {
dir: 'rtl',
search: 'חפש...',
results: '12 תוצאות',
searching: 'מחפש...',
saving: 'שומר שינויים...',
savingStatus: 'שומר...',
textarea: 'אזור טקסט',
comment: 'כתוב תגובה...',
post: 'פרסם',
footer: 'כותרת תחתונה ממוקמת מתחת לאזור הטקסט.',
},
en: {
dir: 'ltr',
search: 'Search...',
results: '12 results',
searching: 'Searching...',
saving: 'Saving changes...',
savingStatus: 'Saving...',
textarea: 'Textarea',
comment: 'Write a comment...',
post: 'Post',
footer: 'Footer positioned below the textarea.',
},
} as const
export default function InputGroupRtlExample() {
let language = $state<keyof typeof translations>('ar')
const text = () => translations[language]
return (
<div class="grid gap-4">
<select
value={language}
onChange={event => {
language = event.currentTarget.value as keyof typeof translations
}}
>
<option value="ar">Arabic (العربية)</option>
<option value="he">Hebrew (עברית)</option>
<option value="en">English</option>
</select>
<div class="grid gap-3" dir={text().dir}>
<InputGroup>
<InputGroupInput placeholder={text().search} dir={text().dir} />
<InputGroupAddon align="inline-end">{text().results}</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder={text().searching} dir={text().dir} />
<InputGroupAddon align="inline-end">
<Spinner />
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder={text().saving} dir={text().dir} />
<InputGroupAddon align="inline-end">
<Spinner />
{text().savingStatus}
</InputGroupAddon>
</InputGroup>
<label class="grid gap-2">
{text().textarea}
<InputGroup>
<InputGroupTextarea placeholder={text().comment} dir={text().dir} />
<InputGroupAddon align="block-end">
<span>0/280</span>
<InputGroupButton class="ml-auto">{text().post}</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<small>{text().footer}</small>
</label>
</div>
</div>
)
}