An input with icons, text, buttons or a keyboard hint attached, sharing one border and focus ring.
"use client"
import * as React from "react"
import { IconArrowUp, IconLock, IconSearch } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupClear,
InputGroupCount,
InputGroupInput,
InputGroupPasswordToggle,
InputGroupTextarea,
} from "@/components/ui/input-group"
export function InputGroupDemo() {
const [message, setMessage] = React.useState("")
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<InputGroup>
<InputGroupInput
type="search"
defaultValue="Accordion"
placeholder="Search components"
aria-label="Search components"
/>
<InputGroupAddon>
<IconSearch />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupClear />
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput
type="password"
defaultValue="hunter2-but-longer"
autoComplete="current-password"
aria-label="Password"
/>
<InputGroupAddon>
<IconLock />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupPasswordToggle />
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupTextarea
value={message}
onChange={(event) => setMessage(event.target.value)}
maxLength={140}
placeholder="Leave a note for the team"
aria-label="Note"
/>
<InputGroupAddon align="block-end">
<InputGroupCount />
<InputGroupButton
variant="default"
size="icon-xs"
aria-label="Post note"
disabled={!message.trim()}
className="ms-auto"
>
<IconArrowUp />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/input-group.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/input-group.tsx "use client" import * as React from "react" import { flushSync } from "react-dom" import { Input as InputPrimitive } from "@base-ui/react/input" import { IconEye, IconEyeOff, IconX } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { easeOut, prefersReducedMotion, useSizeMorph } from "@/lib/motion" import { Button } from "@/components/ui/button" import { useAutosize } from "@/hooks/use-autosize" import { useComposedRef } from "@/hooks/use-composed-ref" import { useInvalidShake } from "@/hooks/use-invalid-shake" import { useMergedRef } from "@/hooks/use-merged-ref" import { Input, InputCount, type InputControl, type InputCountProps, } from "@/components/ui/input" type InputGroupSize = "sm" | "default" | "lg" type InputGroupContextValue = { size: InputGroupSize controlRef: React.RefObject<InputControl | null> value: string maxLength: number | null disabled: boolean readOnly: boolean revealed: boolean setRevealed: (revealed: boolean) => void sync: () => void clearRef: React.RefObject<(() => void) | null> } const InputGroupContext = React.createContext<InputGroupContextValue | null>( null ) function useInputGroup(part: string) { const context = React.useContext(InputGroupContext) if (!context) { throw new Error(`<${part}> must be used within <InputGroup>.`) } return context } function noop() {} function useControlContext() { const context = React.useContext(InputGroupContext) const controlRef = React.useRef<InputControl | null>(null) const clearRef = React.useRef<(() => void) | null>(null) const fallback = React.useMemo<InputGroupContextValue>( () => ({ size: "default", controlRef, clearRef, value: "", maxLength: null, disabled: false, readOnly: false, revealed: false, setRevealed: noop, sync: noop, }), [] ) return context ?? fallback } type ControlSnapshot = Pick< InputGroupContextValue, "value" | "maxLength" | "disabled" | "readOnly" > const emptySnapshot: ControlSnapshot = { value: "", maxLength: null, disabled: false, readOnly: false, } function readControl(control: InputControl | null): ControlSnapshot { if (!control) { return emptySnapshot } return { value: control.value, maxLength: control.maxLength > 0 ? control.maxLength : null, disabled: control.disabled, readOnly: control.readOnly, } } function sameSnapshot(a: ControlSnapshot, b: ControlSnapshot) { return ( a.value === b.value && a.maxLength === b.maxLength && a.disabled === b.disabled && a.readOnly === b.readOnly ) } const control = "[data-slot=input-group-control]" const inputGroupVariants = cva( "group/input-group relative flex w-full min-w-0 items-center rounded-(--input-group-radius) bg-transparent text-sm inset-ring-(length:--hairline) inset-ring-input transition-[color,background-color,box-shadow] duration-150 ease-out-cubic outline-none [--input-group-radius:var(--radius-md)] focus-visible:outline-hidden has-[[data-slot=input-group-control]:disabled]:cursor-not-allowed has-[[data-slot=input-group-control]:disabled]:opacity-50 has-[[data-slot=input-group-control]:focus]:ring-3 has-[[data-slot=input-group-control]:focus]:ring-focus-ring has-[[data-slot=input-group-control]:focus]:inset-ring-ring has-[[data-slot=input-group-control]:user-invalid]:inset-ring-destructive has-[[data-slot=input-group-control]:user-invalid:focus]:ring-destructive/20 has-[[data-slot=input-group-control][aria-invalid=true]]:inset-ring-destructive has-[[data-slot=input-group-control][aria-invalid=true]:focus]:ring-destructive/20 has-[[data-slot=input-group-control][data-invalid]]:inset-ring-destructive has-[[data-slot=input-group-control][data-invalid]:focus]:ring-destructive/20 has-[[data-slot=input-group-control][readonly]]:bg-muted/40 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:items-stretch has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:items-stretch has-[>textarea]:h-auto has-[>textarea]:items-stretch data-shake:motion-safe:animate-button-shake motion-reduce:transition-none dark:bg-input/30 dark:has-[[data-slot=input-group-control]:user-invalid:focus]:ring-destructive/40 dark:has-[[data-slot=input-group-control][aria-invalid=true]:focus]:ring-destructive/40 dark:has-[[data-slot=input-group-control][data-invalid]:focus]:ring-destructive/40 dark:has-[[data-slot=input-group-control][readonly]]:bg-input/20 forced-colors:border data-disabled:cursor-not-allowed data-disabled:opacity-50 [@media(hover:hover)]:hover:not-has-[[data-slot=input-group-control]:focus]:not-has-[[data-slot=input-group-control][aria-invalid=true]]:not-has-[[data-slot=input-group-control][data-invalid]]:not-has-[[data-slot=input-group-control]:user-invalid]:not-has-[[data-slot=input-group-control]:disabled]:not-has-[[data-slot=input-group-control][readonly]]:not-data-disabled:inset-ring-ring/70", { variants: { size: { sm: "h-(--input-group-height) [--input-group-height:calc(var(--spacing)*8)]", default: "h-(--input-group-height) [--input-group-height:calc(var(--spacing)*9)]", lg: "h-(--input-group-height) [--input-group-height:calc(var(--spacing)*10)]", }, }, defaultVariants: { size: "default", }, } ) type InputGroupProps = React.ComponentProps<"div"> & VariantProps<typeof inputGroupVariants> function InputGroup({ className, size = "default", ...props }: InputGroupProps) { const resolvedSize = size ?? "default" const controlRef = React.useRef<InputControl | null>(null) const clearRef = React.useRef<(() => void) | null>(null) const [snapshot, setSnapshot] = React.useState(emptySnapshot) const [revealed, setRevealed] = React.useState(false) const sync = React.useCallback(() => { const next = readControl(controlRef.current) setSnapshot((previous) => (sameSnapshot(previous, next) ? previous : next)) }, []) const context = React.useMemo<InputGroupContextValue>( () => ({ size: resolvedSize, controlRef, ...snapshot, revealed, setRevealed, sync, clearRef, }), [resolvedSize, snapshot, revealed, sync] ) return ( <InputGroupContext.Provider value={context}> <div role="group" data-slot="input-group" data-size={resolvedSize} data-filled={snapshot.value ? "" : undefined} className={cn(inputGroupVariants({ size: resolvedSize }), className)} {...props} /> </InputGroupContext.Provider> ) } function useControlSync( context: InputGroupContextValue, value: unknown, extra: { disabled?: boolean; readOnly?: boolean; maxLength?: number } ) { const { controlRef, sync, setRevealed } = context React.useLayoutEffect(() => { sync() }, [sync, value, extra.disabled, extra.readOnly, extra.maxLength]) React.useEffect(() => { const element = controlRef.current if (!element) { return } const form = element.form let timer: ReturnType<typeof setTimeout> | undefined const onReset = () => { clearTimeout(timer) timer = setTimeout(sync) } const onSubmit = () => { flushSync(() => setRevealed(false)) } const onInput = (event: Event) => { if (event.target === element) { sync() } } window.addEventListener("input", onInput) form?.addEventListener("reset", onReset) form?.addEventListener("submit", onSubmit) return () => { window.removeEventListener("input", onInput) form?.removeEventListener("reset", onReset) form?.removeEventListener("submit", onSubmit) clearTimeout(timer) } }, [controlRef, sync, setRevealed]) } function handleClearKey( event: React.KeyboardEvent<InputControl>, context: InputGroupContextValue ) { if ( event.key !== "Escape" || event.defaultPrevented || event.nativeEvent.isComposing || !context.clearRef.current || !event.currentTarget.value || event.currentTarget.readOnly ) { return } event.preventDefault() event.stopPropagation() context.clearRef.current() } const buttonSizes = "has-[>[data-addon-button][data-size=xs]]:[--input-group-button-size:calc(var(--spacing)*6)] has-[>[data-addon-button][data-size=icon-xs]]:[--input-group-button-size:calc(var(--spacing)*6)] has-[>[data-addon-button][data-size=sm]]:[--input-group-button-size:calc(var(--spacing)*8)] has-[>[data-addon-button][data-size=icon-sm]]:[--input-group-button-size:calc(var(--spacing)*8)] has-[>kbd]:[--input-group-button-size:calc(var(--spacing)*5)]" const insetAddon = "has-[>[data-addon-button]]:ps-(--input-group-addon-inset) has-[>kbd]:ps-(--input-group-addon-inset)" const insetBlockEdges = "has-[>[data-addon-button]:first-child]:ps-(--input-group-addon-inset) has-[>[data-addon-button]:last-child]:pe-(--input-group-addon-inset)" const inputGroupAddonVariants = cva( cn( "flex h-auto min-w-0 cursor-text items-center gap-1.5 text-sm text-muted-foreground select-none [--input-group-addon-inset:max(calc(var(--spacing)*0.5),calc((var(--input-group-height)-var(--input-group-button-size,calc(var(--spacing)*6)))/2))] group-has-[[data-slot=input-group-control]:disabled]/input-group:cursor-not-allowed group-data-disabled/input-group:cursor-not-allowed data-morphing:overflow-clip [&>kbd]:inline-flex [&>kbd]:h-5 [&>kbd]:min-w-5 [&>kbd]:items-center [&>kbd]:justify-center [&>kbd]:rounded-[max(calc(var(--radius-sm)*0.5),calc(var(--input-group-radius)-var(--input-group-addon-inset)))] [&>kbd]:bg-muted [&>kbd]:px-1 [&>kbd]:font-sans [&>kbd]:text-xs [&>kbd]:text-muted-foreground [&>kbd]:inset-ring-(length:--hairline) [&>kbd]:inset-ring-border [&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg:not([class*='size-'])]:size-4", buttonSizes ), { variants: { align: { "inline-start": cn( "order-first shrink-0 self-stretch ps-3", insetAddon ), "inline-end": "order-last shrink-0 self-stretch pe-3 has-[>[data-addon-button]]:pe-(--input-group-addon-inset) has-[>kbd]:pe-(--input-group-addon-inset)", "block-start": cn( "order-first w-full justify-start px-3 pt-2.5 [--input-group-addon-inset:max(calc(var(--spacing)*1.5),calc((var(--input-group-height)-var(--input-group-button-size,calc(var(--spacing)*6)))/2))] has-[>[data-addon-button]]:pt-(--input-group-addon-inset) data-separator:pb-2.5 data-separator:shadow-[inset_0_calc(-1*var(--hairline))_0_var(--color-border)] has-[>[data-addon-button]]:data-separator:pb-(--input-group-addon-inset)", insetBlockEdges ), "block-end": cn( "order-last w-full justify-start px-3 pb-2.5 [--input-group-addon-inset:max(calc(var(--spacing)*1.5),calc((var(--input-group-height)-var(--input-group-button-size,calc(var(--spacing)*6)))/2))] has-[>[data-addon-button]]:pb-(--input-group-addon-inset) data-separator:pt-2.5 data-separator:shadow-[inset_0_var(--hairline)_0_var(--color-border)] has-[>[data-addon-button]]:data-separator:pt-(--input-group-addon-inset)", insetBlockEdges ), }, }, defaultVariants: { align: "inline-start", }, } ) type InputGroupAddonProps = React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants> & { separator?: boolean } function focusControl(event: React.MouseEvent<HTMLDivElement>) { const target = event.target as HTMLElement if (target.closest("button, a, input, textarea, select, [tabindex]")) { return } const group = event.currentTarget.closest("[data-slot=input-group]") const field = group?.querySelector<HTMLElement>(control) if (field && !field.matches(":disabled")) { event.preventDefault() field.focus() } } function InputGroupAddon({ className, align = "inline-start", separator = false, onMouseDown, ref, ...props }: InputGroupAddonProps) { const resolvedAlign = align ?? "inline-start" const morphRef = useSizeMorph<HTMLDivElement>({ axis: "width", enabled: resolvedAlign.startsWith("inline"), duration: 220, }) const setRef = useMergedRef(ref, morphRef) return ( <div ref={setRef} role="group" data-slot="input-group-addon" data-align={resolvedAlign} data-separator={separator ? "" : undefined} className={cn(inputGroupAddonVariants({ align }), className)} onMouseDown={(event) => { onMouseDown?.(event) if (!event.defaultPrevented) { focusControl(event) } }} {...props} /> ) } const inputGroupButtonVariants = cva( "rounded-[max(calc(var(--radius-sm)*0.5),calc(var(--input-group-radius)-var(--input-group-addon-inset)))]" ) type InputGroupButtonProps = Omit< React.ComponentProps<typeof Button>, "size" > & { size?: "xs" | "sm" | "icon-xs" | "icon-sm" } function InputGroupButton({ className, type = "button", variant = "ghost", size = "xs", ...props }: InputGroupButtonProps) { return ( <Button type={type} variant={variant} size={size} data-slot="input-group-button" data-addon-button="" data-size={size} className={cn(inputGroupButtonVariants(), className)} {...props} /> ) } function InputGroupText({ className, ...props }: React.ComponentProps<"span">) { return ( <span data-slot="input-group-text" className={cn( "flex min-w-0 items-center gap-1.5 overflow-hidden text-sm whitespace-nowrap text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} /> ) } function InputGroupInput({ className, type, ref, onKeyDown, ...props }: React.ComponentProps<typeof Input>) { const context = useControlContext() const [, setRef] = useComposedRef<HTMLInputElement>(ref) const setControl = React.useCallback( (node: HTMLInputElement | null) => { context.controlRef.current = node setRef(node) }, [context.controlRef, setRef] ) useControlSync(context, props.value, { disabled: props.disabled, readOnly: props.readOnly, maxLength: props.maxLength, }) return ( <Input ref={setControl} data-slot="input-group-control" size={context.size} type={type === "password" && context.revealed ? "text" : type} onKeyDown={(event) => { onKeyDown?.(event) handleClearKey(event, context) }} className={cn( "h-auto min-h-(--input-group-height) min-w-16 flex-1 self-stretch rounded-none bg-transparent! inset-ring-0 group-has-[>[data-align=inline-end]]/input-group:pe-2 group-has-[>[data-align=inline-start]]/input-group:ps-2 focus-visible:ring-0 disabled:opacity-100 group-has-[[data-slot=input-group-clear]]/input-group:[&::-webkit-search-cancel-button]:appearance-none", className )} {...props} /> ) } type InputGroupTextareaProps = React.ComponentProps<"textarea"> & { autoResize?: boolean shake?: boolean } function InputGroupTextarea({ className, autoResize = true, shake = true, ref, onKeyDown, ...props }: InputGroupTextareaProps) { const context = useControlContext() const [textareaRef, setRef] = useComposedRef<HTMLTextAreaElement>(ref) const setControl = React.useCallback( (node: HTMLTextAreaElement | null) => { context.controlRef.current = node setRef(node) }, [context.controlRef, setRef] ) useAutosize(textareaRef, autoResize, props.value) useInvalidShake(textareaRef, shake) useControlSync(context, props.value, { disabled: props.disabled, readOnly: props.readOnly, maxLength: props.maxLength, }) return ( <InputPrimitive data-slot="input-group-control" render={<textarea ref={setControl} />} onKeyDown={(event) => { const keyEvent = event as unknown as React.KeyboardEvent<HTMLTextAreaElement> onKeyDown?.(keyEvent) handleClearKey(keyEvent, context) }} className={cn( "max-h-64 min-h-16 w-full min-w-0 flex-1 resize-none overscroll-none rounded-none bg-transparent px-3 py-2.5 text-sm text-foreground outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:outline-hidden disabled:cursor-not-allowed pointer-coarse:text-[max(16px,1rem)]", className )} {...(props as InputPrimitive.Props)} /> ) } function setControlValue(element: InputControl, value: string) { element.focus() element.select() let done = false try { done = typeof document.execCommand === "function" && document.execCommand(value ? "insertText" : "delete", false, value) } catch { done = false } if (done && element.value === value) { return } const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype Object.getOwnPropertyDescriptor(prototype, "value")?.set?.call(element, value) element.dispatchEvent(new Event("input", { bubbles: true })) } const reveal = "transition-[opacity,scale,filter] duration-200 ease-out-quint motion-reduce:transition-none" type InputGroupClearProps = Omit<InputGroupButtonProps, "size" | "children"> & { onClear?: () => void children?: React.ReactNode } function InputGroupClear({ className, onClear, onClick, onMouseDown, children, "aria-label": ariaLabel = "Clear", ...props }: InputGroupClearProps) { const context = useInputGroup("InputGroupClear") const { controlRef, clearRef } = context const visible = Boolean( context.value && !context.disabled && !context.readOnly ) const clear = React.useCallback(() => { const element = controlRef.current if (!element) { return } setControlValue(element, "") onClear?.() }, [controlRef, onClear]) React.useLayoutEffect(() => { clearRef.current = clear return () => { if (clearRef.current === clear) { clearRef.current = null } } }, [clearRef, clear]) return ( <InputGroupButton size="icon-xs" tabIndex={-1} aria-label={ariaLabel} aria-hidden={visible ? undefined : true} data-slot="input-group-clear" data-visible={visible ? "" : undefined} className={cn( reveal, "text-muted-foreground not-data-visible:pointer-events-none not-data-visible:scale-50 not-data-visible:opacity-0 not-data-visible:blur-[2px] hover:text-foreground", className )} onMouseDown={(event) => { onMouseDown?.(event) if (!event.defaultPrevented) { event.preventDefault() } }} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) { clear() } }} {...props} > {children ?? <IconX />} </InputGroupButton> ) } type InputGroupPasswordToggleProps = Omit< InputGroupButtonProps, "size" | "children" > & { revealed?: boolean onRevealedChange?: (revealed: boolean) => void } function InputGroupPasswordToggle({ className, revealed: revealedProp, onRevealedChange, onClick, onMouseDown, "aria-label": ariaLabel = "Show password", ...props }: InputGroupPasswordToggleProps) { const context = useInputGroup("InputGroupPasswordToggle") const { controlRef, setRevealed } = context const revealed = revealedProp ?? context.revealed React.useLayoutEffect(() => { if (revealedProp !== undefined) { setRevealed(revealedProp) } }, [revealedProp, setRevealed]) React.useEffect(() => () => setRevealed(false), [setRevealed]) return ( <InputGroupButton size="icon-xs" aria-label={ariaLabel} aria-pressed={revealed} disabled={context.disabled} data-slot="input-group-password-toggle" data-revealed={revealed ? "" : undefined} className={cn("grid *:col-start-1 *:row-start-1", className)} onMouseDown={(event) => { onMouseDown?.(event) if ( !event.defaultPrevented && controlRef.current === document.activeElement ) { event.preventDefault() } }} onClick={(event) => { onClick?.(event) if (event.defaultPrevented) { return } const element = controlRef.current const start = element?.selectionStart ?? null const end = element?.selectionEnd ?? null const next = !revealed if (revealedProp === undefined) { flushSync(() => setRevealed(next)) } onRevealedChange?.(next) if (element && start !== null && end !== null) { try { element.setSelectionRange(start, end) } catch {} } }} {...props} > <IconEye aria-hidden="true" className={cn(reveal, revealed && "scale-50 opacity-0 blur-[2px]")} /> <IconEyeOff aria-hidden="true" className={cn(reveal, !revealed && "scale-50 opacity-0 blur-[2px]")} /> </InputGroupButton> ) } type InputGroupCountProps = Omit< InputCountProps, "length" | "maxLength" | "controlRef" > function InputGroupCount(props: InputGroupCountProps) { const context = useInputGroup("InputGroupCount") return ( <InputCount data-slot="input-group-count" length={context.value.length} maxLength={context.maxLength} controlRef={context.controlRef} {...props} /> ) } export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupClear, InputGroupCount, InputGroupInput, InputGroupPasswordToggle, InputGroupText, InputGroupTextarea, inputGroupAddonVariants, inputGroupButtonVariants, inputGroupVariants, useInputGroup, } export type { InputGroupAddonProps, InputGroupButtonProps, InputGroupClearProps, InputGroupCountProps, InputGroupPasswordToggleProps, InputGroupProps, InputGroupTextareaProps, }components/ui/input.tsx "use client" import * as React from "react" import { Input as InputPrimitive } from "@base-ui/react/input" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { useAutosize } from "@/hooks/use-autosize" import { useComposedRef } from "@/hooks/use-composed-ref" import { useInvalidShake } from "@/hooks/use-invalid-shake" import { useMergedRef } from "@/hooks/use-merged-ref" import { prefersReducedMotion } from "@/lib/motion" import { NumberFlow } from "@/components/ui/number-flow" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } const inputVariants = cva( "w-full min-w-0 rounded-md bg-transparent text-sm text-foreground inset-ring-(length:--hairline) inset-ring-input transition-[color,background-color,box-shadow] duration-150 ease-out-cubic outline-none selection:bg-primary selection:text-primary-foreground file:me-3 file:inline-flex file:h-full file:items-center file:border-0 file:bg-transparent file:p-0 file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground placeholder:transition-opacity placeholder:duration-150 user-invalid:inset-ring-destructive focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden focus-visible:placeholder:opacity-70 user-invalid:focus-visible:ring-destructive/70 user-invalid:focus-visible:inset-ring-destructive disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:inset-ring-destructive aria-invalid:focus-visible:ring-destructive/70 aria-invalid:focus-visible:inset-ring-destructive data-invalid:inset-ring-destructive data-invalid:focus-visible:ring-destructive/70 data-invalid:focus-visible:inset-ring-destructive data-shake:motion-safe:animate-button-shake motion-reduce:transition-none dark:bg-input/30 dark:scheme-dark dark:user-invalid:focus-visible:ring-destructive/70 dark:aria-invalid:focus-visible:ring-destructive/70 dark:data-invalid:focus-visible:ring-destructive/70 forced-colors:border pointer-coarse:text-[max(16px,1rem)] [&::-webkit-search-cancel-button]:cursor-pointer [&::-webkit-search-cancel-button]:opacity-50 [&::-webkit-search-cancel-button]:transition-opacity [&::-webkit-search-cancel-button:hover]:opacity-100 [&[readonly]]:bg-muted/40 dark:[&[readonly]]:bg-input/20 [@media(hover:hover)]:hover:not-focus-visible:not-disabled:not-[[readonly]]:not-aria-invalid:not-data-invalid:not-user-invalid:inset-ring-ring/70", { variants: { size: { sm: "h-8 px-2.5 file:pe-2.5", default: "h-9 px-3", lg: "h-10 px-3.5", }, }, defaultVariants: { size: "default", }, } ) type InputControl = HTMLInputElement | HTMLTextAreaElement type InputProps = Omit<InputPrimitive.Props, "size"> & VariantProps<typeof inputVariants> & { htmlSize?: number shake?: boolean } function Input({ className, size = "default", htmlSize, shake = true, ref, ...props }: InputProps) { const resolvedSize = size ?? "default" const [inputRef, setRef] = useComposedRef<HTMLInputElement>(ref) useInvalidShake(inputRef, shake) return ( <InputPrimitive ref={setRef} data-slot="input" data-size={resolvedSize} size={htmlSize} className={mergeClassName( inputVariants({ size: resolvedSize }), className )} {...props} /> ) } function defaultAnnouncement(remaining: number) { return remaining === 0 ? "Character limit reached" : `${remaining} ${remaining === 1 ? "character" : "characters"} left` } type InputCountProps = Omit<React.ComponentProps<"span">, "children"> & { length: number maxLength?: number | null controlRef?: React.RefObject<InputControl | null> threshold?: number announcement?: (remaining: number) => string } function InputCount({ className, length, maxLength = null, controlRef, threshold, announcement = defaultAnnouncement, ref, ...props }: InputCountProps) { const max = maxLength !== null && maxLength > 0 ? maxLength : null const remaining = max === null ? null : Math.max(0, max - length) const near = max === null ? Infinity : Math.max(1, threshold ?? Math.ceil(Math.min(max * 0.1, 20))) const state = remaining === null ? undefined : remaining === 0 ? "limit" : remaining <= near ? "near" : undefined const [message, setMessage] = React.useState("") const [lastState, setLastState] = React.useState(state) if (state !== lastState) { setLastState(state) setMessage(state && remaining !== null ? announcement(remaining) : "") } const [countRef, setCountRef] = useComposedRef<HTMLSpanElement>(ref) React.useEffect(() => { const element = controlRef?.current const count = countRef.current if (!element || !count || max === null) { return } let timer: ReturnType<typeof setTimeout> | undefined const onBeforeInput = (event: Event) => { const input = event as InputEvent if ( !input.inputType?.startsWith("insert") || element.value.length < max || element.selectionStart !== element.selectionEnd || prefersReducedMotion() ) { return } count.removeAttribute("data-bump") void count.offsetWidth count.setAttribute("data-bump", "") clearTimeout(timer) timer = setTimeout(() => count.removeAttribute("data-bump"), 400) } element.addEventListener("beforeinput", onBeforeInput) return () => { element.removeEventListener("beforeinput", onBeforeInput) clearTimeout(timer) } }, [controlRef, max]) return ( <span ref={setCountRef} data-slot="input-count" data-state={state} className={cn( "inline-flex shrink-0 items-baseline text-xs text-muted-foreground tabular-nums transition-colors duration-200 ease-out-cubic data-bump:animate-button-shake data-[state=limit]:text-destructive data-[state=near]:text-foreground", className )} {...props} > <span aria-hidden="true" dir="ltr" className="inline-flex items-baseline"> <NumberFlow value={length} /> {max !== null && <span>/{max}</span>} </span> <span role="status" className="sr-only"> {message} </span> </span> ) } export { Input, InputCount, useAutosize, inputVariants, useComposedRef, useInvalidShake, useMergedRef, } export type { InputControl, InputCountProps, InputProps }components/ui/button.tsx "use client" import * as React from "react" import { Button as ButtonPrimitive } from "@base-ui/react/button" import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Spinner } from "@/components/ui/spinner" import { useButtonFeedback, type ButtonFeedbackOptions, type ButtonStatus, } from "@/hooks/use-button-feedback" const buttonVariants = cva( "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70", link: "text-primary underline-offset-4 hover:underline forced-colors:border-0", success: "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40", error: "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake", }, size: { default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5", lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", icon: "size-9 shrink-0", "icon-xs": "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden", "icon-lg": "size-10 shrink-0", }, }, defaultVariants: { variant: "default", size: "default", }, } ) type ButtonVariant = Exclude< NonNullable<VariantProps<typeof buttonVariants>["variant"]>, "success" | "error" > type ButtonClickEvent = Parameters< NonNullable<ButtonPrimitive.Props["onClick"]> >[0] type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> & Omit<VariantProps<typeof buttonVariants>, "variant"> & ButtonFeedbackOptions & { variant?: ButtonVariant | null onClick?: (event: ButtonClickEvent) => unknown feedback?: boolean loading?: boolean status?: ButtonStatus loadingLabel?: React.ReactNode successLabel?: React.ReactNode errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode) } const stateTransitionTime = 220 const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"] const quietVariants: ButtonVariant[] = ["ghost", "link"] const quietStatusClasses: Partial<Record<ButtonStatus, string>> = { success: "text-success hover:text-success", error: "text-destructive hover:text-destructive motion-safe:animate-button-shake", } function isThenable(value: unknown): value is PromiseLike<unknown> { return ( typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function" ) } function useStatusTransition(status: ButtonStatus) { const [shown, setShown] = React.useState(status) const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null) const [transitionKey, setTransitionKey] = React.useState(0) if (status !== shown) { setLeaving(shown) setShown(status) setTransitionKey((key) => key + 1) } React.useEffect(() => { if (leaving === null) { return } const timer = setTimeout(() => setLeaving(null), stateTransitionTime) return () => clearTimeout(timer) }, [leaving, transitionKey]) return { leaving, transitionKey } } function useLayerWidth(status: ButtonStatus, active: boolean) { const contentRef = React.useRef<HTMLSpanElement>(null) const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>()) const observerRef = React.useRef<ResizeObserver | null>(null) const statusRef = React.useRef(status) const applyWidth = React.useCallback(() => { const content = contentRef.current const layer = layersRef.current.get(statusRef.current) if (!content || !layer) { return } const natural = parseFloat(getComputedStyle(layer).width) if (!Number.isFinite(natural)) { return } content.style.width = `${natural}px` if (!content.hasAttribute("data-measured")) { requestAnimationFrame(() => content.setAttribute("data-measured", "")) } }, []) React.useLayoutEffect(() => { statusRef.current = status if (active) { applyWidth() } }, [active, applyWidth, status]) React.useLayoutEffect(() => { if (!active || typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(applyWidth) observerRef.current = observer layersRef.current.forEach((layer) => observer.observe(layer)) document.fonts?.addEventListener?.("loadingdone", applyWidth) return () => { observer.disconnect() observerRef.current = null document.fonts?.removeEventListener?.("loadingdone", applyWidth) } }, [active, applyWidth]) const layerRef = React.useCallback((node: HTMLElement | null) => { if (!node) { return } const layer = node.dataset.layer as ButtonStatus layersRef.current.set(layer, node) observerRef.current?.observe(node) return () => { observerRef.current?.unobserve(node) if (layersRef.current.get(layer) === node) { layersRef.current.delete(layer) } } }, []) return { contentRef, layerRef } } function composeHandlers<Event>( first: ((event: Event) => void) | undefined, second: (event: Event) => void ) { return (event: Event) => { first?.(event) second(event) } } function Button({ className, variant = "default", size = "default", children, onClick, disabled, focusableWhenDisabled, feedback, loading, status: statusProp, onStatusChange, onError, resetAfter, loadingLabel, successLabel = "Done", errorLabel = "Failed", onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: ButtonProps) { const internal = useButtonFeedback({ resetAfter, onStatusChange, onError }) const status = statusProp ?? (loading ? "loading" : internal.status) const requested = Boolean(feedback) || loading !== undefined || statusProp !== undefined const [enabled, setEnabled] = React.useState(requested) if (requested && !enabled) { setEnabled(true) } const { leaving, transitionKey } = useStatusTransition(status) const iconOnly = size?.startsWith("icon") ?? false const widthStatus = status === "loading" && loadingLabel === undefined ? "idle" : status const { contentRef, layerRef } = useLayerWidth( widthStatus, enabled && !iconOnly ) const isLoading = status === "loading" const quiet = quietVariants.includes(variant ?? "default") const resolvedVariant = !quiet && status === "success" ? "success" : !quiet && status === "error" ? "error" : variant const resolvedErrorLabel = typeof errorLabel !== "function" ? errorLabel : internal.error !== undefined ? errorLabel(internal.error) : "Failed" const handleClick = (event: ButtonClickEvent) => { if (feedback && internal.isPending()) { return } const result = onClick?.(event) if (feedback && isThenable(result)) { internal.track(result) } } const layerContent = (layer: ButtonStatus, animated: boolean) => { if (layer === "idle") { return children } if (layer === "loading") { return ( <> <Spinner aria-hidden size={null} animated={animated} /> {iconOnly ? null : loadingLabel} </> ) } if (layer === "success") { return ( <> <IconCircleCheck className={cn( animated && "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]" )} /> {iconOnly ? null : successLabel} </> ) } return ( <> <IconAlertCircle /> {iconOnly ? null : resolvedErrorLabel} </> ) } const button = ( <ButtonPrimitive data-slot="button" data-status={enabled ? status : undefined} aria-busy={isLoading || undefined} disabled={disabled || isLoading} focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)} className={cn( buttonVariants({ variant: resolvedVariant, size }), quiet && quietStatusClasses[status], className )} onClick={handleClick} onPointerEnter={composeHandlers( onPointerEnter, internal.buttonProps.onPointerEnter )} onPointerLeave={composeHandlers( onPointerLeave, internal.buttonProps.onPointerLeave )} onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)} onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)} {...props} > {enabled ? ( <span ref={contentRef} data-slot="button-content" className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]" > {statuses.map((layer) => { const active = layer === status const exiting = layer === leaving const namesButton = layer === "idle" && isLoading && loadingLabel === undefined return ( <span key={active ? `${layer}-${transitionKey}` : layer} ref={layerRef} data-slot="button-layer" data-layer={layer} aria-hidden={active || namesButton ? undefined : true} className={cn( "inline-flex items-center justify-center gap-[inherit] backface-hidden", !active && !exiting && "pointer-events-none absolute opacity-0", active && transitionKey > 0 && "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0", exiting && "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out" )} > {layerContent(layer, active || exiting)} </span> ) })} </span> ) : ( children )} </ButtonPrimitive> ) if (!enabled) { return button } const announcement = status === "loading" ? typeof loadingLabel === "string" ? loadingLabel : "Loading" : status === "success" ? typeof successLabel === "string" ? successLabel : "Done" : status === "error" ? typeof resolvedErrorLabel === "string" ? resolvedErrorLabel : "Failed" : "" return ( <> {button} <span data-slot="button-status" role="status" className="sr-only"> {announcement} </span> </> ) } export { Button, buttonVariants, useButtonFeedback } export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }components/ui/number-flow.tsx "use client" import * as React from "react" import { cn } from "cn" import { easeSpring } from "@/lib/motion" type NumberFlowTrend = "auto" | "up" | "down" | "shortest" type NumberFlowChar = { key: string section: number order: number value: string digit: number | null } type NumberFlowItem = NumberFlowChar & { exiting: boolean exitId: number } type NumberFlowProps = Omit< React.ComponentProps<"span">, "children" | "prefix" > & { value: number locales?: Intl.LocalesArgument format?: Intl.NumberFormatOptions prefix?: string suffix?: string trend?: NumberFlowTrend duration?: number easing?: string animated?: boolean onAnimationsStart?: () => void onAnimationsFinish?: () => void } const rows = 30 const restingClasses = "data-[digit='0']:translate-y-[-33.3333%] data-[digit='1']:translate-y-[-36.6667%] data-[digit='2']:translate-y-[-40%] data-[digit='3']:translate-y-[-43.3333%] data-[digit='4']:translate-y-[-46.6667%] data-[digit='5']:translate-y-[-50%] data-[digit='6']:translate-y-[-53.3333%] data-[digit='7']:translate-y-[-56.6667%] data-[digit='8']:translate-y-[-60%] data-[digit='9']:translate-y-[-63.3333%]" function getSpinDelta( from: number, to: number, trend: Exclude<NumberFlowTrend, "auto"> ) { let delta = to - from if (trend === "up") { if (delta < 0) { delta += 10 } } else if (trend === "down") { if (delta > 0) { delta -= 10 } } else if (delta > 5) { delta -= 10 } else if (delta < -4) { delta += 10 } return delta } function digitGlyphs( formatter: Intl.NumberFormat, locales: Intl.LocalesArgument ) { const { numberingSystem } = formatter.resolvedOptions() const plain = new Intl.NumberFormat(locales, { numberingSystem, useGrouping: false, }) return Array.from({ length: 10 }, (_, digit) => plain.format(digit)) } function formatChars( value: number, locales: Intl.LocalesArgument, format: Intl.NumberFormatOptions | undefined, prefix: string | undefined, suffix: string | undefined ) { const formatter = new Intl.NumberFormat(locales, format) const glyphs = digitGlyphs(formatter, locales) const parts = formatter.formatToParts(value) const integerCount = parts .filter((part) => part.type === "integer") .reduce((count, part) => count + Array.from(part.value).length, 0) const chars: NumberFlowChar[] = [] const occurrences = new Map<string, number>() let integerIndex = 0 let fractionIndex = 0 let seenNumber = false function symbol(section: number, type: string, text: string) { const name = `${section}-${type}` const occurrence = occurrences.get(name) ?? 0 occurrences.set(name, occurrence + 1) chars.push({ key: `s${name}-${occurrence}`, section, order: occurrence, value: text, digit: null, }) } if (prefix) { chars.push({ key: "prefix", section: 0, order: 0, value: prefix, digit: null, }) } for (const part of parts) { if (part.type === "integer") { seenNumber = true for (const glyph of Array.from(part.value)) { const place = integerCount - 1 - integerIndex const digit = glyphs.indexOf(glyph) chars.push({ key: `i${place}`, section: 2, order: -place, value: glyph, digit: digit === -1 ? null : digit, }) integerIndex += 1 } } else if (part.type === "group") { const place = integerCount - 1 - integerIndex chars.push({ key: `g${place}`, section: 2, order: -place - 0.5, value: part.value, digit: null, }) } else if (part.type === "decimal") { seenNumber = true chars.push({ key: "decimal", section: 3, order: 0, value: part.value, digit: null, }) } else if (part.type === "fraction") { for (const glyph of Array.from(part.value)) { const digit = glyphs.indexOf(glyph) chars.push({ key: `f${fractionIndex}`, section: 4, order: fractionIndex, value: glyph, digit: digit === -1 ? null : digit, }) fractionIndex += 1 } } else { symbol(seenNumber ? 5 : 1, part.type, part.value) } } if (suffix) { chars.push({ key: "suffix", section: 6, order: 0, value: suffix, digit: null, }) } return chars } function signature(chars: NumberFlowChar[]) { return chars.map((char) => `${char.key}:${char.value}`).join("|") } function mergeItems(previous: NumberFlowItem[], next: NumberFlowChar[]) { const keys = new Set(next.map((char) => char.key)) const kept = previous .filter((item) => !keys.has(item.key)) .map((item) => item.exiting ? item : { ...item, exiting: true, exitId: item.exitId + 1 } ) const fresh = next.map((char) => { const before = previous.find((item) => item.key === char.key) return { ...char, exiting: false, exitId: before?.exitId ?? 0 } }) return [...kept, ...fresh].sort( (a, b) => a.section - b.section || a.order - b.order ) } function subscribeReducedMotion(callback: () => void) { if (typeof window.matchMedia !== "function") { return () => {} } const query = window.matchMedia("(prefers-reduced-motion: reduce)") query.addEventListener("change", callback) return () => query.removeEventListener("change", callback) } function getReducedMotion() { return ( typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } function canAnimate() { return ( typeof Element !== "undefined" && typeof Element.prototype.animate === "function" ) } function liveIndex(column: HTMLElement) { const height = column.offsetHeight if (!height) { return null } const translate = getComputedStyle(column).translate const y = translate === "none" ? "0" : (translate.split(" ")[1] ?? "0") const amount = parseFloat(y) || 0 return y.endsWith("%") ? (-amount / 100) * rows : (-amount / height) * rows } function NumberFlow({ value, locales = "en-US", format, prefix, suffix, trend = "auto", duration = 600, easing = easeSpring, animated = true, onAnimationsStart, onAnimationsFinish, className, ...props }: NumberFlowProps) { const chars = formatChars(value, locales, format, prefix, suffix) const key = signature(chars) const reducedMotion = React.useSyncExternalStore( subscribeReducedMotion, getReducedMotion, () => false ) const animate = animated && !reducedMotion && canAnimate() const [state, setState] = React.useState(() => ({ key, items: chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), })) if (state.key !== key) { setState({ key, items: animate ? mergeItems(state.items, chars) : chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), }) } const rootRef = React.useRef<HTMLSpanElement>(null) const previousRef = React.useRef<Map< string, { digit: number | null; exiting: boolean; exitId: number } > | null>(null) const valueRef = React.useRef(value) const batchRef = React.useRef(0) const callbacksRef = React.useRef({ onAnimationsStart, onAnimationsFinish }) const finishExitRef = React.useRef((itemKey: string, exitId: number) => { setState((current) => ({ ...current, items: current.items.filter( (item) => !(item.key === itemKey && item.exiting && item.exitId === exitId) ), })) }) React.useLayoutEffect(() => { callbacksRef.current = { onAnimationsStart, onAnimationsFinish } }) React.useLayoutEffect(() => { const root = rootRef.current const previous = previousRef.current const current = new Map( state.items.map((item) => [ item.key, { digit: item.digit, exiting: item.exiting, exitId: item.exitId }, ]) ) const previousValue = valueRef.current previousRef.current = current valueRef.current = value if (!root || !previous || !animate) { return } const direction = trend !== "auto" ? trend : value > previousValue ? "up" : value < previousValue ? "down" : "shortest" const timing = { duration, easing } const started: Animation[] = [] const cells = root.querySelectorAll<HTMLElement>( ":scope > [data-number-flow-key]" ) for (const cell of cells) { const itemKey = cell.dataset.numberFlowKey! const item = current.get(itemKey) const before = previous.get(itemKey) if (!item || typeof cell.animate !== "function") { continue } const column = cell.querySelector<HTMLElement>( "[data-slot=number-flow-column]" ) const entering = !item.exiting && (!before || before.exiting) const leaving = item.exiting && (!before || !before.exiting) if (entering || leaving) { const running = cell.getAnimations() const fromWidth = running.length ? cell.getBoundingClientRect().width : entering ? 0 : cell.getBoundingClientRect().width const fromOpacity = running.length ? Number(getComputedStyle(cell).opacity) : entering ? 0 : 1 running.forEach((animation) => animation.cancel()) const toWidth = entering ? cell.getBoundingClientRect().width : 0 const animation = cell.animate( [ { width: `${fromWidth}px`, opacity: fromOpacity }, { width: `${toWidth}px`, opacity: entering ? 1 : 0 }, ], { ...timing, fill: leaving ? "forwards" : "none" } ) started.push(animation) if (leaving) { const exitId = item.exitId animation.finished.then( () => finishExitRef.current(itemKey, exitId), () => {} ) } } if (!column || item.digit === null || item.exiting) { continue } const target = item.digit const running = column.getAnimations() const live = running.length ? liveIndex(column) : null running.forEach((animation) => animation.cancel()) const from = live !== null ? live - 10 : entering ? 0 : before && before.digit !== null ? before.digit : target if (live === null && from === target && !entering) { continue } const delta = getSpinDelta( from, target, entering && live === null ? "up" : direction ) if (delta === 0) { continue } started.push( column.animate( [ { translate: `0 ${(-(target + 10 - delta) / rows) * 100}%` }, { translate: `0 ${(-(target + 10) / rows) * 100}%` }, ], timing ) ) } if (!started.length) { return } batchRef.current += 1 const batch = batchRef.current callbacksRef.current.onAnimationsStart?.() Promise.all(started.map((animation) => animation.finished)).then( () => { if (batch === batchRef.current) { callbacksRef.current.onAnimationsFinish?.() } }, () => {} ) }, [state.items, animate, duration, easing, trend, value]) return ( <span ref={rootRef} dir="ltr" className={cn("inline-block max-w-full tabular-nums", className)} {...props} data-slot="number-flow" > <span data-slot="number-flow-text" className="sr-only"> {state.items .filter((item) => !item.exiting) .map((item) => item.value) .join("")} </span> {state.items.map((item) => item.digit === null ? ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-symbol" aria-hidden className="inline-block max-w-full overflow-clip wrap-anywhere whitespace-pre-wrap" > {item.value} </span> ) : ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-digit" aria-hidden className="relative -my-[0.2em] inline-block overflow-clip [mask-image:linear-gradient(to_bottom,transparent,black_0.2em,black_calc(100%-0.2em),transparent)] py-[0.2em]" > <span className="opacity-0">{item.value}</span> <span aria-hidden data-slot="number-flow-column" data-digit={item.digit} className={cn( "pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center select-none", restingClasses )} > {Array.from({ length: rows }, (_, row) => ( <span key={row} className="block py-[0.2em]"> {glyphFor(item, row)} </span> ))} </span> </span> ) )} </span> ) } function glyphFor(item: NumberFlowItem, row: number) { return String.fromCodePoint( item.value.codePointAt(0)! - (item.digit ?? 0) + (row % 10) ) } export { NumberFlow, getSpinDelta, formatChars, type NumberFlowProps, type NumberFlowTrend, }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupClear,
InputGroupCount,
InputGroupInput,
InputGroupPasswordToggle,
InputGroupText,
InputGroupTextarea,
} from "@/components/ui/input-group"<InputGroup>
<InputGroupInput placeholder="Search…" aria-label="Search" />
<InputGroupAddon>
<IconSearch />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupClear />
</InputGroupAddon>
</InputGroup>Put <InputGroupInput /> or <InputGroupTextarea /> first and the addons after it. Addons place themselves with align, so the field comes first in the tab order and for screen readers.
The group tracks its field, so the smart parts need no wiring. <InputGroupClear />, <InputGroupPasswordToggle /> and <InputGroupCount /> read the value, type and maxLength from the field, whether it's controlled or not.
InputGroup
├── InputGroupAddon
│ ├── InputGroupText
│ ├── InputGroupButton
│ ├── InputGroupClear
│ ├── InputGroupCount
│ └── InputGroupPasswordToggle
└── InputGroupInput / InputGroupTextareaIcon#
Icons sit inside the border on either side. Clicking an icon focuses the input, so the whole group feels like one field.
import { IconCheck, IconMail, IconSearch } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
export function InputGroupIcon() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<InputGroup>
<InputGroupInput placeholder="Search…" aria-label="Search" />
<InputGroupAddon>
<IconSearch />
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput
type="email"
defaultValue="[email protected]"
aria-label="Email"
/>
<InputGroupAddon>
<IconMail />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<IconCheck className="text-success" aria-label="Verified" />
</InputGroupAddon>
</InputGroup>
</div>
)
}Clear#
<InputGroupClear /> fades in once there's a value. It clears through the browser's edit history, so Cmd+Z brings the text back, and it fires your onChange. Escape clears too. A second Escape is left for the dialog or popover around it.
Apple, Apricot
"use client"
import * as React from "react"
import { IconSearch } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupClear,
InputGroupInput,
} from "@/components/ui/input-group"
const fruits = ["Apple", "Apricot", "Banana", "Blackberry", "Cherry", "Fig"]
export function InputGroupClearDemo() {
const [query, setQuery] = React.useState("ap")
const matches = fruits.filter((fruit) =>
fruit.toLowerCase().includes(query.trim().toLowerCase())
)
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<InputGroup>
<InputGroupInput
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter fruit"
aria-label="Filter fruit"
/>
<InputGroupAddon>
<IconSearch />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupClear />
</InputGroupAddon>
</InputGroup>
<p className="text-sm text-muted-foreground">
{matches.length > 0 ? matches.join(", ") : "No fruit matches."}
</p>
</div>
)
}Character count#
<InputGroupCount /> counts against the field's maxLength. Only the digits that change roll. The count darkens near the limit and turns red at it, and a typed key past the limit nudges it. Screen readers hear a message when the field gets close to the limit and when it reaches it, never on every key.
import { IconAt } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupCount,
InputGroupInput,
} from "@/components/ui/input-group"
export function InputGroupCountDemo() {
return (
<InputGroup className="max-w-sm">
<InputGroupInput
defaultValue="hextaui"
maxLength={15}
autoComplete="username"
aria-label="Username"
/>
<InputGroupAddon>
<IconAt />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupCount />
</InputGroupAddon>
</InputGroup>
)
}Text#
Use <InputGroupText /> for units, currencies and URL parts. The input's padding shrinks next to an addon so the text reads as one value.
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"
export function InputGroupTextDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<InputGroup>
<InputGroupInput
placeholder="0.00"
inputMode="decimal"
aria-label="Price"
/>
<InputGroupAddon>
<InputGroupText>$</InputGroupText>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupText>USD</InputGroupText>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder="hextaui" aria-label="Domain" />
<InputGroupAddon>
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupText>.com</InputGroupText>
</InputGroupAddon>
</InputGroup>
</div>
)
}Button#
<InputGroupButton /> is a ghost button sized to fit inside the field. Its corners are concentric with the group's, and it gets its own focus ring.
"use client"
import * as React from "react"
import { IconCheck, IconCopy, IconX } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
export function InputGroupButtonDemo() {
const [copied, setCopied] = React.useState(false)
const [query, setQuery] = React.useState("button group")
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<InputGroup>
<InputGroupInput
readOnly
defaultValue="https://hextaui.com/docs/input-group"
aria-label="Share link"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={copied ? "Copied" : "Copy link"}
onClick={async () => {
await navigator.clipboard
?.writeText("https://hextaui.com/docs/input-group")
.catch(() => {})
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}}
>
{copied ? <IconCheck /> : <IconCopy />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search components…"
aria-label="Search components"
/>
<InputGroupAddon align="inline-end">
{query ? (
<InputGroupButton
size="icon-xs"
aria-label="Clear search"
onClick={() => setQuery("")}
>
<IconX />
</InputGroupButton>
) : null}
<InputGroupButton variant="secondary">Search</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
)
}Keyboard hint#
A plain <kbd> inside an addon is styled as a key cap. It's a visual hint only, so bind the shortcut yourself.
import { IconSearch } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
export function InputGroupKbd() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<InputGroup>
<InputGroupInput placeholder="Search docs…" aria-label="Search docs" />
<InputGroupAddon>
<IconSearch />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<kbd>⌘</kbd>
<kbd>K</kbd>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput
placeholder="Jump to file…"
aria-label="Jump to file"
/>
<InputGroupAddon align="inline-end">
<kbd>/</kbd>
</InputGroupAddon>
</InputGroup>
</div>
)
}Textarea#
<InputGroupTextarea /> grows with its content up to 16rem, then scrolls. The height eases between lines instead of jumping. A block-end addon becomes a toolbar under it, and buttons at its edges get the same inset as the corner they sit in.
"use client"
import * as React from "react"
import { IconArrowUp, IconPaperclip } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupCount,
InputGroupTextarea,
} from "@/components/ui/input-group"
export function InputGroupTextareaDemo() {
const [value, setValue] = React.useState("")
return (
<InputGroup className="max-w-sm">
<InputGroupTextarea
value={value}
onChange={(event) => setValue(event.target.value)}
maxLength={280}
placeholder="Write a reply…"
aria-label="Reply"
/>
<InputGroupAddon align="block-end">
<InputGroupButton size="icon-xs" aria-label="Attach a file">
<IconPaperclip />
</InputGroupButton>
<InputGroupCount className="ms-auto" />
<InputGroupButton
variant="default"
size="icon-xs"
aria-label="Send"
disabled={!value.trim()}
>
<IconArrowUp />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
)
}Header#
A block-start addon sits above the field. Add separator to draw a hairline between them.
import { IconCopy, IconFileCode } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupTextarea,
} from "@/components/ui/input-group"
export function InputGroupBlockStart() {
return (
<InputGroup className="max-w-sm">
<InputGroupTextarea
defaultValue={'console.log("Hello from HextaUI")'}
aria-label="script.js"
/>
<InputGroupAddon align="block-start" separator>
<InputGroupText>
<IconFileCode />
script.js
</InputGroupText>
<InputGroupButton size="icon-xs" aria-label="Copy" className="ms-auto">
<IconCopy />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
)
}Password#
<InputGroupPasswordToggle /> switches a type="password" field to text and back. The caret and selection stay where they were, a mouse click keeps focus in the field, and the password is hidden again when the form submits. Control it with revealed.
import { IconLock } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupPasswordToggle,
} from "@/components/ui/input-group"
export function InputGroupPassword() {
return (
<InputGroup className="max-w-sm">
<InputGroupInput
type="password"
defaultValue="correct horse battery staple"
autoComplete="current-password"
aria-label="Password"
/>
<InputGroupAddon>
<IconLock />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupPasswordToggle />
</InputGroupAddon>
</InputGroup>
)
}Sizes#
size on the group sets the height and passes down to the input, matching <Input /> sizes. Buttons keep concentric corners at every size.
import { IconSearch } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
export function InputGroupSizes() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{(["sm", "default", "lg"] as const).map((size) => (
<InputGroup key={size} size={size}>
<InputGroupInput
placeholder={`Size ${size}`}
aria-label={`Size ${size}`}
/>
<InputGroupAddon>
<IconSearch />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupButton>Go</InputGroupButton>
</InputGroupAddon>
</InputGroup>
))}
</div>
)
}Invalid#
Set aria-invalid on the input and the whole group turns red, including its focus ring. Link the message with aria-describedby.
Enter a complete email address.
import { IconAt, IconAlertCircle } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
export function InputGroupInvalid() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<InputGroup>
<InputGroupInput
defaultValue="preet@"
aria-label="Email"
aria-invalid
aria-describedby="email-error"
/>
<InputGroupAddon>
<IconAt />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<IconAlertCircle className="text-destructive" aria-hidden />
</InputGroupAddon>
</InputGroup>
<p id="email-error" className="text-sm text-destructive">
Enter a complete email address.
</p>
</div>
)
}Disabled#
A disabled input dims the whole group and shows a not-allowed cursor over it. Disable addon buttons too, since they stay usable otherwise.
import { IconLink } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
export function InputGroupDisabled() {
return (
<InputGroup className="max-w-sm">
<InputGroupInput
disabled
defaultValue="https://hextaui.com"
aria-label="Link"
/>
<InputGroupAddon>
<IconLink />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupButton disabled>Copy</InputGroupButton>
</InputGroupAddon>
</InputGroup>
)
}Loading#
Inline addons ease to their new width when their content changes, so the field never jumps as a spinner turns into a result count. The spinner only turns when motion is allowed, and role="status" announces the text.
"use client"
import * as React from "react"
import { IconLoader2, IconSearch } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"
const packages = ["react", "react-dom", "react-hook-form", "redux", "remix"]
export function InputGroupLoading() {
const [query, setQuery] = React.useState("re")
const [pending, setPending] = React.useState(false)
const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
const results = packages.filter((name) => name.includes(query.trim()))
React.useEffect(() => () => clearTimeout(timer.current), [])
return (
<InputGroup className="max-w-sm">
<InputGroupInput
value={query}
onChange={(event) => {
setQuery(event.target.value)
setPending(true)
clearTimeout(timer.current)
timer.current = setTimeout(() => setPending(false), 600)
}}
aria-label="Search packages"
aria-busy={pending}
/>
<InputGroupAddon>
<IconSearch />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupText role="status">
{pending ? (
<>
<IconLoader2 className="motion-safe:animate-spin" aria-hidden />
Searching
</>
) : results.length === 1 ? (
"1 package"
) : (
`${results.length} packages`
)}
</InputGroupText>
</InputGroupAddon>
</InputGroup>
)
}Dropdown#
Render an <InputGroupButton /> as a dropdown trigger to scope the input.
import { IconChevronDown } from "@tabler/icons-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
export function InputGroupDropdown() {
return (
<InputGroup className="max-w-sm">
<InputGroupInput placeholder="Enter a search term" aria-label="Search" />
<InputGroupAddon align="inline-end">
<DropdownMenu>
<DropdownMenuTrigger render={<InputGroupButton />}>
Everywhere
<IconChevronDown data-icon="inline-end" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem>Everywhere</DropdownMenuItem>
<DropdownMenuItem>This project</DropdownMenuItem>
<DropdownMenuItem>Documentation</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</InputGroupAddon>
</InputGroup>
)
}Long content#
Long values scroll inside the input instead of stretching the group. Wrap long addon text in a truncating span with a max width.
import { IconWorld } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"
export function InputGroupLongContent() {
return (
<div className="flex w-72 flex-col gap-4">
<InputGroup>
<InputGroupInput
defaultValue="averyveryverylongunbrokenvaluethatkeepsgoingandgoing"
aria-label="Value"
/>
<InputGroupAddon>
<IconWorld />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupText>.example.com</InputGroupText>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder="name" aria-label="Subdomain" />
<InputGroupAddon>
<InputGroupText className="max-w-28">
<span className="truncate">
https://workspace-with-a-long-name.
</span>
</InputGroupText>
</InputGroupAddon>
</InputGroup>
</div>
)
}Right to left#
Addons, padding and corner radii use logical sides, so inline-start lands on the right.
import { IconArrowUp, IconSearch } from "@tabler/icons-react"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"
export function InputGroupRtl() {
return (
<div dir="rtl" className="flex w-full max-w-sm flex-col gap-4">
<InputGroup>
<InputGroupInput placeholder="ابحث…" aria-label="بحث" />
<InputGroupAddon>
<IconSearch />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<kbd>⌘K</kbd>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder="0.00" aria-label="السعر" />
<InputGroupAddon>
<InputGroupText>ر.س</InputGroupText>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupButton variant="default" size="icon-xs" aria-label="إرسال">
<IconArrowUp />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
)
}| Key | Action |
|---|---|
| Tab | Moves from the field to each addon button, in source order. |
| ShiftTab | Moves back through the buttons and the field. |
| Escape | With an InputGroupClear, clears the field. When it's already empty, Escape passes through. |
- Every field needs a name. Use a visible label, a Field, or
aria-label. Icons and addon text aren't part of the field's name. - Give icon-only buttons an
aria-label. - When addon text carries meaning, like a currency or a domain, add it to the label or reference it with
aria-describedby. <InputGroupClear />is skipped in the tab order because Escape does the same thing. The password toggle stays tabbable and keeps the same name, witharia-pressedreporting its state.- When a submit finds the field invalid, the group shakes once. With reduced motion the red border is the only cue.
- On touch screens the field text is at least 16px so phones don't zoom in when it's focused.
<InputGroupInput /> and <InputGroupTextarea /> accept the props of the elements they render. The other parts accept their element's attributes.
| Prop | Type | Default |
|---|---|---|
sizeHeight of the group, passed down to the input. | "sm" | "default" | "lg" | "default" |
| Attribute | Description |
|---|---|
data-slot="input-group" | Target the group in CSS. Renders role="group". |
data-size | The current size. |
data-filled | Present while the field has a value. |
data-shake | Present while the group shakes after a failed submit. |
data-disabled | Set it yourself to dim the group when only the addons are disabled. |
--input-group-radius | Corner radius of the group. Buttons and key caps derive their radius from it. |
--input-group-height | Height of the group for the current size. |
| Prop | Type | Default |
|---|---|---|
sizeInherited from the group. | "sm" | "default" | "lg" | – |
aria-invalid | boolean | – |
disabled | boolean | false |
readOnly | boolean | false |
| Attribute | Description |
|---|---|
data-slot="input-group-control" | Marks the field. The group reads its focus, invalid, disabled and read-only states from it. |
data-invalid | Present when a surrounding Field marks the value invalid. |
data-disabled | Present when the field is disabled. |
data-focused | Present while the field has focus. |
data-filled | Present when the field has a value. |
data-dirty | Present once the value differs from the initial one. |
data-touched | Present once the field has been focused and left. |
| Prop | Type | Default |
|---|---|---|
autoResizeGrow with the content up to 16rem, easing between heights. | boolean | true |
shakeShake the group when a submit finds it invalid. | boolean | true |
rows | number | – |
aria-invalid | boolean | – |
disabled | boolean | false |
| Attribute | Description |
|---|---|
data-slot="input-group-control" | Marks the field. The group reads its focus, invalid, disabled and read-only states from it. |
data-invalid | Present when a surrounding Field marks the value invalid. |
data-disabled | Present when the field is disabled. |
data-focused | Present while the field has focus. |
data-filled | Present when the field has a value. |
data-dirty | Present once the value differs from the initial one. |
data-touched | Present once the field has been focused and left. |
| Prop | Type | Default |
|---|---|---|
align | "inline-start" | "inline-end" | "block-start" | "block-end" | "inline-start" |
separatorDraw a hairline between a block addon and the field. | boolean | false |
| Attribute | Description |
|---|---|
data-slot="input-group-addon" | Target addons in CSS. |
data-align | The current alignment. |
data-separator | Present when separator is set. |
--input-group-addon-inset | Space between the group's edge and a button or key cap inside it. |
| Prop | Type | Default |
|---|---|---|
variant | "default" | "outline" | "secondary" | "ghost" | "destructive" | "link" | "ghost" |
size | "xs" | "sm" | "icon-xs" | "icon-sm" | "xs" |
type | string | "button" |
feedbackEvery Button prop works, including the loading and success flow. | boolean | false |
| Attribute | Description |
|---|---|
data-slot="input-group-button" | Target addon buttons in CSS. |
data-size | The current size. |
| Prop | Type | Default |
|---|---|---|
onClearCalled after the field is cleared. | () => void | – |
aria-label | string | "Clear" |
children | ReactNode | <IconX /> |
| Attribute | Description |
|---|---|
data-slot="input-group-clear" | Target the clear button in CSS. |
data-visible | Present while the field has a value and is editable. |
| Prop | Type | Default |
|---|---|---|
revealedControlled state. Leave unset to let it manage itself. | boolean | – |
onRevealedChange | (revealed: boolean) => void | – |
aria-label | string | "Show password" |
| Attribute | Description |
|---|---|
data-slot="input-group-password-toggle" | Target the toggle in CSS. |
data-revealed | Present while the password is shown. |
| Prop | Type | Default |
|---|---|---|
thresholdCharacters left when the count starts to stand out. | number | 10% of maxLength, at most 20 |
announcementMessage for screen readers when the count crosses the threshold or hits the limit. | (remaining: number) => string | – |
| Attribute | Description |
|---|---|
data-slot="input-group-count" | Target the count in CSS. |
data-state="near" | "limit" | Present within the threshold, and when no characters are left. |
data-bump | Present briefly when a key is pressed at the limit. |
| Attribute | Description |
|---|---|
data-slot="input-group-text" | Target addon text in CSS. |