InputGroup
An input and its icons, prefixes, buttons and help, inside one hairline that focuses as one.
The idea
The box is the field, so it owns everything a field shows: hover steps its hairline, focus anywhere inside haloes the whole box rather than the bare input in the middle, and an invalid control turns the whole edge ink. The input itself has no edge — two borders, one inside the other, read as two fields. Inline buttons sit four pixels in with corners four pixels tighter, so the curves run parallel, and pressing an addon's icon or text puts the caret in the field the way a label would.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { InputGroup, Kbd } from "mihirmodi-ui";
<InputGroup>
<InputGroup.Addon>
<SearchIcon />
</InputGroup.Addon>
<InputGroup.Input aria-label="Search" placeholder="Search" />
<InputGroup.Addon align="inline-end">
<Kbd keys={["⌘", "K"]} />
</InputGroup.Addon>
</InputGroup>
<InputGroup>
<InputGroup.Textarea aria-label="Reply" minRows={2} value={reply} onChange={(e) => setReply(e.target.value)} />
<InputGroup.Addon align="block-end">
<span>Shift + Enter for a new line</span>
<InputGroup.Button variant="primary" icon aria-label="Send" onClick={send}>
<SendIcon />
</InputGroup.Button>
</InputGroup.Addon>
</InputGroup>Props
| Prop | Type | Notes |
|---|---|---|
| size"sm" | "md" | "lg" | "sm" | "md" | "lg"= "md" | The box's height; inline buttons size themselves from it. |
| disabledboolean | boolean | Disables the control and every button inside. |
| InputGroup.Input / .Textareacomponent | component | The edgeless control. The textarea grows, like Textarea. |
| InputGroup.Addon align"inline-start" | "inline-end" | "block-start" | "block-end" | "inline-start" | "inline-end" | "block-start" | "block-end"= "inline-start" | Beside the input, or a full row above or below it — a toolbar, a count. |
| InputGroup.Button{ variant?, icon? } | { variant?, icon? } | Ghost by default, `primary` for a send. `icon` makes it square; give it an `aria-label`. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import {
createContext,
forwardRef,
useContext,
type ButtonHTMLAttributes,
type CSSProperties,
type HTMLAttributes,
type InputHTMLAttributes,
type MouseEvent,
} from "react";
import { buttonBase, buttonVariants, type ButtonVariant } from "../actions/Button.js";
import { useFieldControl } from "./Field.js";
import type { ControlSize } from "./Input.js";
import { Textarea, type TextareaProps } from "./Textarea.js";
type GroupCtx = { size: ControlSize; disabled: boolean };
const Ctx = createContext<GroupCtx>({ size: "md", disabled: false });
const BOX: Record<ControlSize, { h: string; cls: string }> = {
sm: { h: "28px", cls: "rounded-[8px] text-[13px]" },
md: { h: "36px", cls: "rounded-[10px] text-[14px]" },
lg: { h: "40px", cls: "rounded-[10px] text-[14px]" },
};
/* Buttons sit 4px in from the edge — the addon's padding drops to 4px when it
holds one — so their corner is the group's minus four. */
const BTN: Record<ControlSize, { text: string; icon: string }> = {
sm: { text: "h-5 gap-1 rounded-[4px] px-1.5 text-[11px]", icon: "h-5 w-5 rounded-[4px]" },
md: { text: "h-7 gap-1.5 rounded-[6px] px-2.5 text-[12px]", icon: "h-7 w-7 rounded-[6px]" },
lg: { text: "h-8 gap-1.5 rounded-[6px] px-3 text-[13px]", icon: "h-8 w-8 rounded-[6px]" },
};
export type InputGroupProps = HTMLAttributes<HTMLDivElement> & {
size?: ControlSize;
/** Disables the control and every button inside. */
disabled?: boolean;
};
/**
* A text control with its company inside the same hairline: a leading icon, a
* `https://` prefix, a unit, a copy button, a count, a row of tools under a
* message.
*
* The box is the field, so it owns everything a field shows. Hover steps its
* hairline, focus anywhere inside lays the halo around the whole box rather
* than the bare input in the middle of it, and an invalid control inside turns
* the whole edge ink. The input itself has no edge at all — two borders, one
* inside the other, would read as two fields.
*
* Inline buttons sit four pixels in from the edge with their corner four
* pixels tighter than the box's, so the curves run parallel. Pressing on an
* addon's icon or text puts the caret in the field, the way pressing a label
* would; only its buttons do anything of their own.
*/
function Root({ size = "md", disabled = false, className = "", style, children, ...rest }: InputGroupProps) {
const box = BOX[size];
return (
<Ctx.Provider value={{ size, disabled }}>
<div
data-ui-input-group=""
data-disabled={disabled || undefined}
style={{ "--ig-h": box.h, ...style } as CSSProperties}
className={`ui-input-group group/ig relative flex w-full min-w-0 flex-wrap items-center border-[0.5px] border-gray-alpha-400 bg-background-100 text-gray-1000 transition-[border-color,box-shadow] duration-150 ease-geist hover:border-gray-alpha-600 focus-within:border-gray-alpha-600 focus-within:shadow-[0_0_0_3px_var(--color-gray-alpha-200)] has-[[aria-invalid=true]]:border-gray-1000 data-[disabled]:cursor-not-allowed data-[disabled]:border-gray-alpha-400 data-[disabled]:opacity-50 motion-reduce:transition-none ${box.cls} ${className}`}
{...rest}
>
{children}
</div>
</Ctx.Provider>
);
}
export type InputGroupInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size"> & { invalid?: boolean };
/** The input inside the box. Edgeless; the group draws the edge. Beside an addon it gives up most of its inset, so an icon sits 8px from the text, not 24. */
const GroupInput = forwardRef<HTMLInputElement, InputGroupInputProps>(function InputGroupInput(
{ invalid, className = "", type = "text", ...rest },
ref
) {
const g = useContext(Ctx);
const field = useFieldControl({ ...rest, invalid, disabled: rest.disabled ?? (g.disabled || undefined) });
return (
<input
ref={ref}
type={type}
{...rest}
{...field}
data-ui-group-control=""
className={`h-[calc(var(--ig-h)-1px)] min-w-0 flex-1 bg-transparent px-3 text-gray-1000 [.ui-input-group:has(>[data-align=inline-start])>&]:pl-2 [.ui-input-group:has(>[data-align=inline-end])>&]:pr-2 placeholder:text-faint disabled:cursor-not-allowed ${className}`}
/>
);
});
export type InputGroupTextareaProps = Omit<TextareaProps, "bare">;
/** A growing textarea inside the box, for a message with tools under it. */
const GroupTextarea = forwardRef<HTMLTextAreaElement, InputGroupTextareaProps>(function InputGroupTextarea(props, ref) {
const g = useContext(Ctx);
return <Textarea ref={ref} {...props} bare disabled={props.disabled ?? (g.disabled || undefined)} />;
});
export type InputGroupAddonProps = HTMLAttributes<HTMLDivElement> & {
/** Beside the input (`inline-*`) or as a full row above or below it (`block-*`). */
align?: "inline-start" | "inline-end" | "block-start" | "block-end";
};
const ALIGN: Record<NonNullable<InputGroupAddonProps["align"]>, string> = {
"inline-start": "order-[-1] h-[calc(var(--ig-h)-1px)] pl-3 has-[>button]:pl-1",
"inline-end": "order-1 h-[calc(var(--ig-h)-1px)] pr-3 has-[>button]:pr-1",
"block-start": "order-[-2] w-full basis-full px-3 pb-1 pt-2.5",
"block-end": "order-2 w-full basis-full px-3 pb-1 pt-1",
};
/** An icon, a prefix or suffix, a button, a line of help — beside the input or on its own row. */
function Addon({ align = "inline-start", className = "", onMouseDown, ...rest }: InputGroupAddonProps) {
const focus = (e: MouseEvent<HTMLDivElement>) => {
onMouseDown?.(e);
if ((e.target as HTMLElement).closest("button, a, input, textarea, select, [role=button]")) return;
const ctl = e.currentTarget.parentElement?.querySelector<HTMLElement>("[data-ui-group-control]:not(:disabled)");
if (!ctl) return;
e.preventDefault();
ctl.focus();
};
return (
<div
data-align={align}
onMouseDown={focus}
className={`flex shrink-0 select-none items-center gap-1.5 text-[13px] text-quiet group-data-[disabled]/ig:pointer-events-none [&>svg]:h-4 [&>svg]:w-4 ${
align.startsWith("block") ? "justify-between" : ""
} ${ALIGN[align]} ${className}`}
{...rest}
/>
);
}
export type InputGroupButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
/** `ghost` by default; `primary` for the one action — a send. */
variant?: ButtonVariant;
/** Square, for an icon alone. Give it an `aria-label`. */
icon?: boolean;
};
/** A button inside the box, sized to sit four pixels in from its edge. */
const GroupButton = forwardRef<HTMLButtonElement, InputGroupButtonProps>(function InputGroupButton(
{ variant = "ghost", icon = false, className = "", type = "button", disabled, ...rest },
ref
) {
const g = useContext(Ctx);
const s = BTN[g.size];
return (
<button
ref={ref}
type={type}
disabled={disabled ?? (g.disabled || undefined)}
className={`${buttonBase} ${buttonVariants[variant]} ${icon ? s.icon : s.text} [[data-align^=block]>&]:first:-ml-2 [[data-align^=block]>&]:last:-mr-2 group-data-[disabled]/ig:disabled:opacity-100 ${className}`}
{...rest}
/>
);
});
/**
* The group and its parts: `InputGroup.Input`, `InputGroup.Textarea`,
* `InputGroup.Addon` and `InputGroup.Button`.
*/
export const InputGroup = Object.assign(Root, {
Input: GroupInput,
Textarea: GroupTextarea,
Addon,
Button: GroupButton,
});
export { GroupInput as InputGroupInput, GroupTextarea as InputGroupTextarea, Addon as InputGroupAddon, GroupButton as InputGroupButton };
"use client";
import {
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ChangeEvent,
type TextareaHTMLAttributes,
} from "react";
import { useFieldControl } from "./Field.js";
import { controlSurface } from "./Input.js";
const useIso = typeof window === "undefined" ? useEffect : useLayoutEffect;
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
/** The height it starts at, in lines. It never shrinks below this. */
minRows?: number;
/** The height it stops growing at, in lines; past it, the text scrolls. */
maxRows?: number;
/** A count under the field — "42 / 280" with `maxLength`, a plain count without. */
showCount?: boolean;
/** Wrong, without a Field around it. */
invalid?: boolean;
/** No hairline or background of its own — for a textarea inside an InputGroup. */
bare?: boolean;
};
/**
* A field for more than a line, that grows as you write.
*
* It grows by measuring a hidden twin rather than itself. The usual trick —
* set the height to `auto`, read `scrollHeight`, set it back — collapses the
* real field for a moment inside the frame, and the page under a long form
* scrolls to follow the collapse: the field you are typing in jumps. The twin
* is never on screen, so the real one only ever moves to its new height. It
* starts at `minRows` from its `rows` attribute, so the server-rendered field is
* already the right size and nothing settles on hydration.
*
* Past `maxRows` it stops and scrolls, because a field that grows without limit
* pushes its own submit button off the screen. The count, when shown, turns
* medium at the limit rather than red, and a screen reader hears how many are
* left once there are few — not after every keystroke from the first.
*/
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{ minRows = 3, maxRows = 10, showCount = false, invalid, bare = false, className = "", onChange, value, defaultValue, maxLength, placeholder, ...rest },
ref
) {
const field = useFieldControl({ ...rest, invalid });
const own = useRef<HTMLTextAreaElement | null>(null);
const twin = useRef<HTMLTextAreaElement | null>(null);
const width = useRef(0);
const [count, setCount] = useState(() => String(value ?? defaultValue ?? "").length);
const setRef = useCallback(
(el: HTMLTextAreaElement | null) => {
own.current = el;
if (typeof ref === "function") ref(el);
else if (ref) ref.current = el;
},
[ref]
);
const measure = useCallback(() => {
const el = own.current;
const t = twin.current;
if (!el || !t) return;
const cs = getComputedStyle(el);
const lh = parseFloat(cs.lineHeight) || 20;
const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
const edge = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
t.style.width = `${el.offsetWidth}px`;
// Measure the placeholder when empty, so a long one is never clipped.
t.value = el.value || placeholder || "";
const content = t.scrollHeight + edge;
const min = minRows * lh + pad + edge;
const max = maxRows * lh + pad + edge;
const h = Math.min(max, Math.max(min, content));
el.style.height = `${h}px`;
el.style.overflowY = content > max ? "auto" : "hidden";
}, [minRows, maxRows, placeholder]);
useIso(() => {
measure();
}, [measure, value]);
useEffect(() => {
const el = own.current;
if (!el) return;
width.current = el.offsetWidth;
// Text rewraps when the field narrows, and the font may arrive late.
const ro = new ResizeObserver(() => {
if (el.offsetWidth !== width.current) {
width.current = el.offsetWidth;
measure();
}
});
ro.observe(el);
document.fonts?.ready.then(measure).catch(() => {});
return () => ro.disconnect();
}, [measure]);
const change = (e: ChangeEvent<HTMLTextAreaElement>) => {
setCount(e.target.value.length);
if (value === undefined) measure();
onChange?.(e);
};
const shown = value !== undefined ? String(value).length : count;
const left = maxLength !== undefined ? maxLength - shown : null;
const near = left !== null && left <= Math.max(10, Math.round((maxLength ?? 0) * 0.1));
const look = bare
? "w-full min-w-0 bg-transparent text-gray-1000 placeholder:text-faint disabled:cursor-not-allowed"
: `${controlSurface} rounded-[10px]`;
const twinLook = bare ? "w-full" : "border-[0.5px]";
return (
<div className={`relative w-full min-w-0 ${showCount ? "flex flex-col gap-1.5" : ""}`}>
<textarea
ref={setRef}
rows={minRows}
value={value}
defaultValue={defaultValue}
maxLength={maxLength}
placeholder={placeholder}
onChange={change}
{...rest}
{...field}
data-ui-group-control={bare ? "" : undefined}
className={`${look} block resize-none px-3 py-2 text-[14px] leading-5 ${className}`}
/>
{/* The twin: same box, same type, never seen, never submitted. */}
<textarea
ref={twin}
aria-hidden
tabIndex={-1}
readOnly
rows={1}
style={{ height: 0, minHeight: 0, maxHeight: "none" }}
className={`${twinLook} pointer-events-none invisible absolute left-0 top-0 -z-10 block resize-none overflow-hidden px-3 py-2 text-[14px] leading-5 ${className}`}
/>
{showCount && (
<div className={`flex justify-end text-[12px] tabular-nums ${bare ? "px-3 pb-2" : ""} ${left !== null && left <= 0 ? "font-medium text-gray-1000" : "text-faint"}`}>
<span aria-hidden>{maxLength !== undefined ? `${shown} / ${maxLength}` : `${shown} ${shown === 1 ? "character" : "characters"}`}</span>
<span className="sr-only" aria-live="polite">
{near ? `${Math.max(0, left ?? 0)} ${left === 1 ? "character" : "characters"} left` : ""}
</span>
</div>
)}
</div>
);
});