Forms
Input Group.
An input or textarea with icons, text prefixes like hub://, and inline buttons attached inside one border.
import { SearchIcon } from "lucide-react";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
export default function InputGroupDemo() {
return (
<div className="grid w-full max-w-sm gap-4">
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput placeholder="Search the directory" aria-label="Search the directory" />
<InputGroupAddon align="inline-end">
<InputGroupText className="text-xs">128 sites</InputGroupText>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupAddon>
<InputGroupText>hub://</InputGroupText>
</InputGroupAddon>
<InputGroupInput placeholder="my-base" aria-label="Site address" className="pl-0.5 font-mono text-[13px]" />
</InputGroup>
</div>
);
}Installation#
With the CLI
$npx lanterncn add input-groupThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/input-group. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install class-variance-authorityCopy the source into your project
components/ui/input-group.tsx"use client"; import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; 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-md border border-input bg-background/40 transition-[border-color,box-shadow] outline-none", "hover:border-muted-foreground/60 has-[>textarea]:h-auto", "has-[>[data-align=inline-start]]:[&>input]:pl-2 has-[>[data-align=inline-end]]:[&>input]:pr-2", "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", "has-[[data-slot=input-group-control]:focus-visible]:border-primary has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/25", "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20", "has-[[data-slot=input-group-control]:disabled]:opacity-50", 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 [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-sm", { variants: { align: { "inline-start": "order-first pl-3 has-[>button]:-ml-1.5 has-[>kbd]:-ml-1", "inline-end": "order-last pr-3 has-[>button]:-mr-2 has-[>kbd]:-mr-1", "block-start": "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3", "block-end": "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3", }, }, 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<HTMLElement>("input, textarea")?.focus(); }} {...props} /> ); } const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm active:translate-y-0", { variants: { size: { xs: "h-6 gap-1 rounded-sm px-2 text-xs has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", sm: "h-8 gap-1.5 rounded-sm px-2.5 text-xs has-[>svg]:px-2.5", "icon-xs": "size-6 rounded-sm p-0 has-[>svg]:p-0", "icon-sm": "size-8 rounded-sm 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"> & VariantProps<typeof inputGroupButtonVariants>) { return ( <Button type={type} data-size={size} variant={variant} className={cn(inputGroupButtonVariants({ size }), "focus-visible:ring-offset-0", className)} {...props} /> ); } function InputGroupText({ className, ...props }: React.ComponentProps<"span">) { return ( <span className={cn( "flex items-center gap-2 font-mono text-[13px] 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( "h-[38px] flex-1 rounded-none border-0 bg-transparent shadow-none hover:border-0 focus-visible:ring-0", 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-3 shadow-none focus-visible:ring-0", className, )} {...props} /> ); } export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea };Update the import paths to match your project
Usage#
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
} from "@/components/ui/input-group";<InputGroup>
<InputGroupAddon>
<InputGroupText>hub://</InputGroupText>
</InputGroupAddon>
<InputGroupInput placeholder="my-base" />
</InputGroup>Addons take align="inline-start", "inline-end", "block-start" or "block-end". Clicking an addon focuses the input.
Examples#
Prefix and suffix
No spaces allowed.
import { AtSignIcon, CheckIcon, GlobeIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
export default function InputGroupAffixes() {
return (
<div className="grid w-full max-w-sm gap-5">
<div className="grid gap-2">
<Label htmlFor="ig-address">Address</Label>
<InputGroup>
<InputGroupAddon>
<InputGroupText>hub://</InputGroupText>
</InputGroupAddon>
<InputGroupInput id="ig-address" defaultValue="ember-library" className="pl-0.5 font-mono text-[13px]" />
<InputGroupAddon align="inline-end">
<CheckIcon className="text-success" aria-label="Available" />
</InputGroupAddon>
</InputGroup>
</div>
<div className="grid gap-2">
<Label htmlFor="ig-domain">Page path</Label>
<InputGroup>
<InputGroupAddon>
<GlobeIcon />
</InputGroupAddon>
<InputGroupInput id="ig-domain" placeholder="guestbook" className="font-mono text-[13px]" />
<InputGroupAddon align="inline-end">
<InputGroupText>.lua</InputGroupText>
</InputGroupAddon>
</InputGroup>
</div>
<div className="grid gap-2">
<Label htmlFor="ig-handle">Player name</Label>
<InputGroup>
<InputGroupAddon>
<AtSignIcon />
</InputGroupAddon>
<InputGroupInput id="ig-handle" aria-invalid defaultValue="steve the builder" />
</InputGroup>
<p className="text-xs text-destructive">No spaces allowed.</p>
</div>
</div>
);
}With buttons
Markdown supported
"use client";
import * as React from "react";
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon, SendIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@/components/ui/input-group";
export default function InputGroupWithButton() {
const [copied, setCopied] = React.useState(false);
const [visible, setVisible] = React.useState(false);
return (
<div className="grid w-full max-w-sm gap-4">
<InputGroup>
<InputGroupInput readOnly value="wget run hub://lantern/install" aria-label="Install command" className="font-mono text-[13px]" />
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={copied ? "Copied" : "Copy command"}
onClick={() => {
navigator.clipboard?.writeText("wget run hub://lantern/install");
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? <CheckIcon className="text-success" /> : <CopyIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput
type={visible ? "text" : "password"}
defaultValue="lantern-key-81"
aria-label="Hub API key"
className="font-mono text-[13px]"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={visible ? "Hide key" : "Show key"}
aria-pressed={visible}
onClick={() => setVisible((v) => !v)}
>
{visible ? <EyeOffIcon /> : <EyeIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder="you@example.com" type="email" aria-label="Email for invite" />
<InputGroupAddon align="inline-end">
<InputGroupButton variant="default" size="sm">
Invite
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupTextarea placeholder="Write in the guestbook..." aria-label="Guestbook message" />
<InputGroupAddon align="block-end" className="border-t">
<InputGroupText className="text-[11px]">Markdown supported</InputGroupText>
<InputGroupButton variant="default" size="sm" className="ml-auto">
<SendIcon />
Post
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
);
}