+ Use Addon to place an icon at the start of the input as a visual identifier.
+
+
+
+
+
+### Text
+
+
Use Addon to place text prefixes or suffixes alongside the input.
+
+
+
+
+### Button
+
+
+ Place `InputGroup.Button` inside an Addon for actions that operate directly on
+ the input value, such as reveal/hide or clear.
+
+
+
+
+
+### Button with Tooltip
+
+
+ Pass a `tooltip` prop to `InputGroup.Button` to show a tooltip on hover. When
+ no explicit `aria-label` is provided, the button derives it from a string
+ tooltip value.
+
+
+
+
+
+### Kbd
+
+
Place a keyboard shortcut hint inside an end Addon.
+
+
+
+
+### Loading
+
+
+ Place a Loader inside an end Addon as a status indicator while validating the
+ input value.
+
+
+
+
+
+### Inline Suffix
+
+
+ Suffix renders text that flows seamlessly next to the typed value — useful for
+ domain inputs like `.workers.dev`. Pair with a status icon Addon to show
+ validation state.
+
+
+
+
+
+### Sizes
+
+
+ Four sizes: `xs`, `sm`, `base` (default), and `lg`. The size applies to the
+ entire group.
+
+
+
+
+
+### States
+
+
+ Various input states including error, disabled, and with description. Pass `label`, `error`, and `description` props directly to `InputGroup`.
+
+ The root container that provides context to all child components. Accepts
+ Field props (`label`, `description`, `error`) and wraps content in a Field
+ when label is provided.
+
+
+
+### `InputGroup.Input`
+
+
+ The text input element. Inherits `size`, `disabled`, and `error` from
+ InputGroup context. Accepts all standard input attributes except Field-related
+ props which are handled by the parent.
+
+
+
+### `InputGroup.Addon`
+
+
+ Container for icons, text, or compact buttons positioned at the start or end
+ of the input.
+
+
+
+### `InputGroup.Button`
+
+
+ Button for secondary actions like toggle, copy, or help. Renders inside an
+ Addon. Pass a `tooltip` prop to show a tooltip on hover.
+
+
+
+### `InputGroup.Suffix`
+
+
+ Inline text that flows seamlessly next to the typed value (e.g.,
+ `.workers.dev`). The input width adjusts automatically as the user types.
+
+
+
+### Validation Error Types
+
+
+ When using `error` as an object, the `match` property corresponds to HTML5 ValidityState values:
+
+
+
+
+
+
Match
+
Description
+
+
+
+
+
valueMissing
+
Required field is empty
+
+
+
typeMismatch
+
Value doesn't match type (e.g., invalid email)
+
+
+
patternMismatch
+
Value doesn't match pattern attribute
+
+
+
tooShort
+
Value shorter than minLength
+
+
+
tooLong
+
Value longer than maxLength
+
+
+
rangeUnderflow
+
Value less than min
+
+
+
rangeOverflow
+
Value greater than max
+
+
+
true
+
Always show error (for server-side validation)
+
+
+
+
+
+
+
+
+## Accessibility
+
+
+
+
+ ### Label Requirement
+
+
+ InputGroup requires an accessible name via one of:
+
+
+
+ `label` prop on InputGroup (renders a visible label with built-in
+ Field support)
+
+
+ `aria-label` on InputGroup.Input for inputs without a visible label
+
+
+ `aria-labelledby` on InputGroup.Input for custom label association
+
+
+
+ Missing accessible names trigger console warnings in development.
+
+
+
+
+ ### Group Role
+
+
+ InputGroup automatically renders with `role="group"`, which semantically
+ associates the input with its addons for assistive technologies.
+
+
+
+
+
diff --git a/packages/kumo/lint/no-primitive-colors.js b/packages/kumo/lint/no-primitive-colors.js
index 49f69e5151..27fd288d02 100644
--- a/packages/kumo/lint/no-primitive-colors.js
+++ b/packages/kumo/lint/no-primitive-colors.js
@@ -74,6 +74,7 @@ const NON_COLOR_UTILITIES = new Set([
"current",
"inherit",
"none",
+ "auto",
// Border utilities (not colors)
"0",
"2",
diff --git a/packages/kumo/package.json b/packages/kumo/package.json
index 700e481a43..ed3d3cca92 100644
--- a/packages/kumo/package.json
+++ b/packages/kumo/package.json
@@ -84,6 +84,10 @@
"types": "./dist/src/components/input/index.d.ts",
"import": "./dist/components/input.js"
},
+ "./components/input-group": {
+ "types": "./dist/src/components/input-group/index.d.ts",
+ "import": "./dist/components/input-group.js"
+ },
"./components/layer-card": {
"types": "./dist/src/components/layer-card/index.d.ts",
"import": "./dist/components/layer-card.js"
diff --git a/packages/kumo/scripts/component-registry/discovery.ts b/packages/kumo/scripts/component-registry/discovery.ts
index c39b1c0c07..f375c27135 100644
--- a/packages/kumo/scripts/component-registry/discovery.ts
+++ b/packages/kumo/scripts/component-registry/discovery.ts
@@ -50,6 +50,7 @@ export const CATEGORY_MAP: Record = {
"date-range-picker": "Input",
field: "Input",
input: "Input",
+ "input-group": "Input",
radio: "Input",
select: "Input",
switch: "Input",
diff --git a/packages/kumo/scripts/component-registry/index.ts b/packages/kumo/scripts/component-registry/index.ts
index a268314c20..886e7f5e25 100644
--- a/packages/kumo/scripts/component-registry/index.ts
+++ b/packages/kumo/scripts/component-registry/index.ts
@@ -611,6 +611,20 @@ async function processComponent(
let usageExamples: string[] | undefined;
let renderElement: string | undefined;
+ // Inject additional props for sub-components (e.g., "InputGroup.Input")
+ const subAdditionalProps =
+ ADDITIONAL_COMPONENT_PROPS[`${config.name}.${subComp.name}`];
+ if (subAdditionalProps) {
+ for (const [propName, propSchema] of Object.entries(
+ subAdditionalProps,
+ )) {
+ subProps[propName] = {
+ ...subProps[propName],
+ ...propSchema,
+ };
+ }
+ }
+
if (subComp.isPassThrough && subComp.baseComponent) {
const passthroughDoc =
PASSTHROUGH_COMPONENT_DOCS[subComp.baseComponent];
diff --git a/packages/kumo/scripts/component-registry/metadata.ts b/packages/kumo/scripts/component-registry/metadata.ts
index 71d39f43ef..ecf12d7b74 100644
--- a/packages/kumo/scripts/component-registry/metadata.ts
+++ b/packages/kumo/scripts/component-registry/metadata.ts
@@ -260,6 +260,45 @@ export const ADDITIONAL_COMPONENT_PROPS: Record<
description: "Callback when checkbox value changes",
},
},
+ "InputGroup.Addon": {
+ align: {
+ type: '"start" | "end"',
+ description: "Position relative to the input.",
+ default: '"start"',
+ },
+ className: {
+ type: "string",
+ description: "Additional CSS classes.",
+ },
+ },
+ "InputGroup.Button": {
+ tooltip: {
+ type: "ReactNode",
+ description:
+ "When provided, wraps the button in a Tooltip. Automatically sets aria-label from a string value.",
+ },
+ tooltipSide: {
+ type: '"top" | "right" | "bottom" | "left"',
+ description: "Preferred side for the tooltip popup.",
+ default: '"bottom"',
+ },
+ variant: {
+ type: '"primary" | "secondary" | "ghost" | "destructive" | "secondary-destructive" | "outline"',
+ description: "Button visual style. Defaults to ghost.",
+ default: '"ghost"',
+ },
+ size: {
+ type: '"xs" | "sm" | "base" | "lg"',
+ description: "Button size.",
+ default: '"sm"',
+ },
+ },
+ "InputGroup.Suffix": {
+ className: {
+ type: "string",
+ description: "Additional CSS classes.",
+ },
+ },
};
// =============================================================================
@@ -318,7 +357,12 @@ export const COMPONENT_STYLING_METADATA: Record = {
],
},
ClipboardText: {
- baseTokens: ["bg-kumo-base", "text-kumo-default", "ring-kumo-line", "border-kumo-fill"],
+ baseTokens: [
+ "bg-kumo-base",
+ "text-kumo-default",
+ "ring-kumo-line",
+ "border-kumo-fill",
+ ],
states: {
input: ["bg-kumo-control", "text-kumo-default", "ring-kumo-line"],
text: ["bg-kumo-base", "font-mono"],
@@ -403,7 +447,12 @@ export const COMPONENT_STYLING_METADATA: Record = {
},
},
Input: {
- baseTokens: ["bg-kumo-control", "text-kumo-default", "text-kumo-subtle", "ring-kumo-line"],
+ baseTokens: [
+ "bg-kumo-control",
+ "text-kumo-default",
+ "text-kumo-subtle",
+ "ring-kumo-line",
+ ],
sizeVariants: {
xs: {
height: 20,
@@ -473,7 +522,12 @@ export const COMPONENT_STYLING_METADATA: Record = {
},
},
Dialog: {
- baseTokens: ["bg-kumo-base", "text-kumo-default", "border-kumo-line", "shadow-m"],
+ baseTokens: [
+ "bg-kumo-base",
+ "text-kumo-default",
+ "border-kumo-line",
+ "shadow-m",
+ ],
sizeVariants: {
sm: {
height: 0, // Dialog height is auto (content-driven)
diff --git a/packages/kumo/src/components/input-group/context.ts b/packages/kumo/src/components/input-group/context.ts
new file mode 100644
index 0000000000..d0afa030bd
--- /dev/null
+++ b/packages/kumo/src/components/input-group/context.ts
@@ -0,0 +1,257 @@
+import {
+ Children,
+ createContext,
+ isValidElement,
+ useContext,
+ type HTMLAttributes,
+ type ReactNode,
+} from "react";
+import type { KumoInputSize } from "../input/input";
+import type { FieldProps } from "../field/field";
+
+// Spacing model
+//
+// Each element type has a fixed outer padding. The container uses has-[] CSS
+// to reduce the input's padding on sides that touch an addon.
+//
+// Input outer: px-3 (12px base) — full padding when at the edge
+// Input seam: pl-2 / pr-2 (8px base) — applied by container has-[]
+// Addon outer: pl-2 / pr-2 (8px base) — on the container-edge side
+// Addon seam: nothing — input owns the gap entirely
+//
+// has-[] rules on the container override [&_input]:pl-{seam} when a start
+// addon is present, and [&_input]:pr-{seam} when an end addon is present.
+
+export interface InputGroupSizeTokens {
+ /** Full outer padding — matches standalone Input (e.g. px-3). */
+ inputOuter: string;
+ /**
+ * Directional outer padding for Addon at the container edge.
+ *
+ * These MUST be static pl-/pr- strings (not derived at runtime via
+ * `"px-N".replace(…)`) so Tailwind JIT can detect them during its
+ * source-file scan. Dynamic string construction produces class names
+ * that never appear as literals, so Tailwind never generates the CSS.
+ */
+ addonOuterStart: string;
+ addonOuterEnd: string;
+ /**
+ * Reduced outer padding when the Addon contains a Button.
+ * Buttons have their own internal padding, so the Addon can use
+ * less outer padding to keep the visual gap balanced.
+ */
+ addonButtonOuterStart: string;
+ addonButtonOuterEnd: string;
+ /** pr- for suffix when no end addon. */
+ suffixPad: string;
+ fontSize: string;
+ /** Icon size in px. */
+ iconSize: number;
+}
+
+export const INPUT_GROUP_SIZE: Record = {
+ xs: {
+ inputOuter: "px-1.5",
+ addonOuterStart: "pl-1.5",
+ addonOuterEnd: "pr-1.5",
+ addonButtonOuterStart: "pl-1",
+ addonButtonOuterEnd: "pr-1",
+ suffixPad: "pr-1.5",
+ fontSize: "text-xs",
+ iconSize: 10,
+ },
+ sm: {
+ inputOuter: "px-2",
+ addonOuterStart: "pl-1.5",
+ addonOuterEnd: "pr-1.5",
+ addonButtonOuterStart: "pl-1",
+ addonButtonOuterEnd: "pr-1",
+ suffixPad: "pr-2",
+ fontSize: "text-xs",
+ iconSize: 13,
+ },
+ base: {
+ inputOuter: "px-3",
+ addonOuterStart: "pl-2",
+ addonOuterEnd: "pr-2",
+ addonButtonOuterStart: "pl-1",
+ addonButtonOuterEnd: "pr-1",
+ suffixPad: "pr-3",
+ fontSize: "text-base",
+ iconSize: 18,
+ },
+ lg: {
+ inputOuter: "px-4",
+ addonOuterStart: "pl-2.5",
+ addonOuterEnd: "pr-2.5",
+ addonButtonOuterStart: "pl-1.5",
+ addonButtonOuterEnd: "pr-1.5",
+ suffixPad: "pr-4",
+ fontSize: "text-base",
+ iconSize: 20,
+ },
+};
+
+// Build the has-[] container classes that reduce input padding when addons
+// are present. These are static strings so Tailwind JIT can detect them.
+export const INPUT_GROUP_HAS_CLASSES: Record = {
+ xs: [
+ "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-1",
+ "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-1",
+ ].join(" "),
+ sm: [
+ "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-1.5",
+ "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-1.5",
+ ].join(" "),
+ base: [
+ "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-2",
+ "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-2",
+ ].join(" "),
+ lg: [
+ "has-[[data-slot=input-group-addon-start]]:[&_input]:pl-2.5",
+ "has-[[data-slot=input-group-addon-end]]:[&_input]:pr-2.5",
+ ].join(" "),
+};
+
+// Context
+
+/**
+ * Props for `InputGroup.Root`. Focus mode is auto-detected from children
+ * (see `detectFocusMode`), so it is not part of the public or internal API.
+ */
+export interface InputGroupRootPropsInternal
+ extends HTMLAttributes,
+ Partial<
+ Pick<
+ FieldProps,
+ "label" | "description" | "error" | "required" | "labelTooltip"
+ >
+ > {
+ size?: KumoInputSize | undefined;
+ disabled?: boolean;
+}
+
+/** Public InputGroup.Root props — identical to the internal type. */
+export type InputGroupRootProps = InputGroupRootPropsInternal;
+
+export interface InputGroupContextValue {
+ size?: KumoInputSize;
+ focusMode: "container" | "individual" | "hybrid";
+ disabled: boolean;
+ error?: FieldProps["error"];
+ /** Auto-generated id for the input element; used by the invisible label overlay. */
+ inputId: string;
+}
+
+export const InputGroupContext = createContext(
+ null,
+);
+
+/**
+ * Set to `true` by `InputGroup.Addon` so that `InputGroup.Button` can detect
+ * whether it's wrapped in an Addon. Ghost buttons should always live inside
+ * an Addon for correct spacing.
+ */
+export const InputGroupAddonContext = createContext(false);
+
+/**
+ * Reads InputGroupContext and warns in development when the context is null
+ * (i.e. when a sub-component is rendered outside of ``).
+ */
+export function useInputGroupContext(componentName: string) {
+ const context = useContext(InputGroupContext);
+ if (process.env.NODE_ENV !== "production" && !context) {
+ console.warn(
+ ` must be used within . Falling back to default values.`,
+ );
+ }
+ return context;
+}
+
+/**
+ * Partitions InputGroup children for hybrid focus mode.
+ *
+ * Container zone: Addon, Input, Suffix, text nodes — everything that should
+ * share a single container-style border.
+ *
+ * Individual zone: Direct `InputGroup.Button` elements that manage their own
+ * border and focus ring.
+ *
+ * Uses `displayName` comparison to identify elements, avoiding circular
+ * imports between `context.ts` and the sub-component files.
+ */
+export function partitionChildren(children: ReactNode): {
+ containerZone: ReactNode[];
+ individualZone: ReactNode[];
+} {
+ const containerZone: ReactNode[] = [];
+ const individualZone: ReactNode[] = [];
+
+ Children.forEach(children, (child) => {
+ if (
+ isValidElement(child) &&
+ (child.type as { displayName?: string })?.displayName ===
+ "InputGroup.Button"
+ ) {
+ individualZone.push(child);
+ } else {
+ containerZone.push(child);
+ }
+ });
+
+ return { containerZone, individualZone };
+}
+
+/**
+ * Analyzes the direct children of `InputGroup` to determine the focus mode.
+ *
+ * Returns `"hybrid"` when BOTH an `InputGroup.Addon` AND a non-ghost direct
+ * `InputGroup.Button` are present. In hybrid mode, Addon+Input share a
+ * container-style border while Buttons get individual borders.
+ *
+ * Returns `"individual"` when a non-ghost direct `InputGroup.Button` is
+ * present WITHOUT any `InputGroup.Addon`. This signals a toolbar/pagination
+ * layout where each element manages its own focus ring.
+ *
+ * Returns `"container"` (default) in all other cases — the container owns a
+ * single shared focus ring.
+ *
+ * Uses `displayName` comparison to identify elements, avoiding circular
+ * imports between `context.ts` and the sub-component files.
+ */
+export function detectFocusMode(
+ children: ReactNode,
+): "container" | "individual" | "hybrid" {
+ let hasNonGhostDirectButton = false;
+ let hasAddon = false;
+
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child)) return;
+
+ // Identify components by displayName to avoid circular imports.
+ const type = child.type;
+ const displayName =
+ typeof type === "function" || typeof type === "object"
+ ? (type as { displayName?: string }).displayName
+ : undefined;
+
+ if (displayName === "InputGroup.Addon") {
+ hasAddon = true;
+ return;
+ }
+
+ if (displayName !== "InputGroup.Button") return;
+
+ // A direct-child Button is by definition NOT inside an Addon (Addon's
+ // children are children of the Addon element, not of InputGroup).
+ // Check whether the variant is explicitly non-ghost.
+ const variant = (child.props as { variant?: string }).variant;
+ if (variant !== undefined && variant !== "ghost") {
+ hasNonGhostDirectButton = true;
+ }
+ });
+
+ if (hasNonGhostDirectButton && hasAddon) return "hybrid";
+ if (hasNonGhostDirectButton) return "individual";
+ return "container";
+}
diff --git a/packages/kumo/src/components/input-group/index.ts b/packages/kumo/src/components/input-group/index.ts
new file mode 100644
index 0000000000..7b3cdc45fe
--- /dev/null
+++ b/packages/kumo/src/components/input-group/index.ts
@@ -0,0 +1,10 @@
+export {
+ InputGroup,
+ KUMO_INPUT_GROUP_VARIANTS,
+ KUMO_INPUT_GROUP_DEFAULT_VARIANTS,
+ type InputGroupRootProps,
+ type InputGroupInputProps,
+ type InputGroupButtonProps,
+ type InputGroupAddonProps,
+ type InputGroupSuffixProps,
+} from "./input-group";
diff --git a/packages/kumo/src/components/input-group/input-group-addon.tsx b/packages/kumo/src/components/input-group/input-group-addon.tsx
new file mode 100644
index 0000000000..e1e5c62245
--- /dev/null
+++ b/packages/kumo/src/components/input-group/input-group-addon.tsx
@@ -0,0 +1,93 @@
+import {
+ Children,
+ cloneElement,
+ forwardRef,
+ isValidElement,
+ type ReactElement,
+ type ReactNode,
+} from "react";
+import { cn } from "../../utils/cn";
+import {
+ useInputGroupContext,
+ INPUT_GROUP_SIZE,
+ InputGroupAddonContext,
+} from "./context";
+import { Button } from "./input-group-button";
+
+export interface InputGroupAddonProps {
+ /** Position relative to the input. @default "start" */
+ align?: "start" | "end";
+ /** Additional CSS classes. */
+ className?: string;
+ /** Addon content: icons, buttons, spinners, text. */
+ children?: ReactNode;
+}
+
+/**
+ * Container for icons, text, or compact buttons positioned at the start or end
+ * of the input. Automatically sizes icon children to match the input size.
+ */
+export const Addon = forwardRef(
+ ({ align = "start", className, children }, ref) => {
+ const context = useInputGroupContext("Addon");
+
+ const size = context?.size ?? "base";
+ const tokens = INPUT_GROUP_SIZE[size];
+
+ // Inject size into direct icon children that don't already have one set.
+ // Skips buttons (which have their own size handling) and non-element nodes.
+ // Also tracks whether a Button is present so we can reduce outer padding.
+ let containsButton = false;
+ const sizedChildren = Children.map(children, (child) => {
+ if (!isValidElement(child)) return child;
+ if (child.type === Button) {
+ containsButton = true;
+ return child;
+ }
+ const props = child.props as { size?: unknown };
+ if (props.size !== undefined) return child;
+ return cloneElement(child as ReactElement<{ size?: number }>, {
+ size: tokens.iconSize,
+ });
+ });
+
+ // Always use flex-based positioning. CSS order controls visual placement.
+ return (
+
+
+ {sizedChildren}
+
+
+ );
+ },
+);
+Addon.displayName = "InputGroup.Addon";
diff --git a/packages/kumo/src/components/input-group/input-group-button.tsx b/packages/kumo/src/components/input-group/input-group-button.tsx
new file mode 100644
index 0000000000..8a4b48f60f
--- /dev/null
+++ b/packages/kumo/src/components/input-group/input-group-button.tsx
@@ -0,0 +1,187 @@
+import React, {
+ forwardRef,
+ useContext,
+ type PropsWithChildren,
+ type ReactNode,
+} from "react";
+import { cn } from "../../utils/cn";
+import { type ButtonProps, Button as ButtonExternal } from "../button/button";
+import { Tooltip, type KumoTooltipSide } from "../tooltip/tooltip";
+import type { KumoInputSize } from "../input/input";
+import {
+ INPUT_GROUP_SIZE,
+ InputGroupAddonContext,
+ useInputGroupContext,
+} from "./context";
+
+/**
+ * In container mode, buttons render "one size down" so they stay visually
+ * subordinate to the input. In individual mode the size passes through
+ * unchanged (pagination / toolbar buttons should match the input height).
+ */
+const COMPACT_BUTTON_SIZE: Record = {
+ xs: "xs",
+ sm: "xs",
+ base: "sm",
+ lg: "base",
+};
+
+export type InputGroupButtonProps = ButtonProps & {
+ /**
+ * When provided, wraps the button in a `Tooltip` showing this content on hover.
+ * Automatically sets `aria-label` from a string value when no `aria-label` is set.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ *
+ * ```
+ */
+ tooltip?: ReactNode;
+ /**
+ * Preferred side for the tooltip popup.
+ * @default "bottom"
+ */
+ tooltipSide?: KumoTooltipSide;
+};
+
+/**
+ * Button for secondary actions rendered inside `InputGroup.Addon`
+ * (toggle, copy, help).
+ *
+ * In `focusMode="container"` (default), renders as a compact ghost button
+ * subordinate to the input. In `focusMode="individual"`, renders as a full
+ * standalone button with its own focus ring, matching toolbar/pagination usage.
+ *
+ * Pass a `tooltip` prop to show a tooltip on hover.
+ */
+export const Button = forwardRef<
+ HTMLButtonElement,
+ PropsWithChildren
+>(
+ (
+ {
+ children,
+ className,
+ variant,
+ size,
+ disabled,
+ tooltip,
+ tooltipSide = "bottom",
+ icon,
+ ...props
+ }: PropsWithChildren,
+ ref: React.ForwardedRef,
+ ) => {
+ const context = useInputGroupContext("Button");
+ const isInsideAddon = useContext(InputGroupAddonContext);
+ const isDisabled = disabled ?? context?.disabled;
+ const isIndividual =
+ context?.focusMode === "individual" || context?.focusMode === "hybrid";
+ const effectiveVariant = variant ?? "ghost";
+
+ if (
+ process.env.NODE_ENV !== "production" &&
+ context &&
+ effectiveVariant === "ghost" &&
+ !isInsideAddon
+ ) {
+ console.warn(
+ "InputGroup.Button: Ghost buttons should be wrapped in for correct spacing.",
+ );
+ }
+
+ if (
+ process.env.NODE_ENV !== "production" &&
+ context &&
+ size !== undefined
+ ) {
+ console.warn(
+ "InputGroup.Button: Set `size` on instead of .",
+ );
+ }
+
+ // Derive aria-label from tooltip string when the button has no explicit label.
+ // Icon-only buttons require an aria-label for a11y.
+ const tooltipAriaLabel =
+ typeof tooltip === "string" && !props["aria-label"] ? tooltip : undefined;
+
+ // Pre-render the icon with the context-derived size so it matches the
+ // Addon icon sizing (e.g. 18px at "base"). Without this, Button's
+ // internal renderIconNode renders `` with no size prop,
+ // falling back to CSS font-size (~14px).
+ const contextIconSize = context
+ ? INPUT_GROUP_SIZE[context.size ?? "base"].iconSize
+ : undefined;
+
+ const sizedIcon =
+ icon &&
+ contextIconSize &&
+ (typeof icon === "function" ||
+ (typeof icon === "object" &&
+ icon !== null &&
+ !React.isValidElement(icon)))
+ ? React.createElement(icon as React.ComponentType<{ size?: number }>, {
+ size: contextIconSize,
+ })
+ : icon;
+
+ const btn = (
+
+ {children}
+
+ );
+
+ if (tooltip) {
+ return (
+
+ {btn}
+
+ );
+ }
+
+ return btn;
+ },
+);
+Button.displayName = "InputGroup.Button";
diff --git a/packages/kumo/src/components/input-group/input-group-input.tsx b/packages/kumo/src/components/input-group/input-group-input.tsx
new file mode 100644
index 0000000000..479779749d
--- /dev/null
+++ b/packages/kumo/src/components/input-group/input-group-input.tsx
@@ -0,0 +1,94 @@
+import { forwardRef } from "react";
+import { cn } from "../../utils/cn";
+import { Input as InputExternal, type InputProps } from "../input/input";
+import { useInputGroupContext, INPUT_GROUP_SIZE } from "./context";
+
+/** Props for InputGroup.Input — omits Field props since InputGroup handles them. */
+export type InputGroupInputProps = Omit<
+ InputProps,
+ "label" | "labelTooltip" | "description" | "error" | "size" | "disabled"
+>;
+
+/**
+ * Text input that inherits size, disabled, and error state from InputGroup context.
+ * Automatically sets `aria-invalid` when parent has an error.
+ */
+export const Input = forwardRef(
+ (props, ref) => {
+ const context = useInputGroupContext("Input");
+
+ // Warn when props that belong on are passed directly
+ if (process.env.NODE_ENV !== "production" && context) {
+ if ((props as any).size !== undefined) {
+ console.warn(
+ "InputGroup.Input: Set `size` on instead of .",
+ );
+ }
+ if ((props as any).disabled !== undefined) {
+ console.warn(
+ "InputGroup.Input: Set `disabled` on instead of .",
+ );
+ }
+ if ((props as any).label !== undefined) {
+ console.warn(
+ "InputGroup.Input: Use the `label` prop on instead of .",
+ );
+ }
+ if ((props as any).description !== undefined) {
+ console.warn(
+ "InputGroup.Input: Use instead of passing `description` to .",
+ );
+ }
+ }
+
+ const size = context?.size ?? "base";
+ const tokens = INPUT_GROUP_SIZE[size];
+ const isIndividual = context?.focusMode === "individual";
+
+ // Auto-set aria-invalid when error is present in context
+ const hasError = Boolean(context?.error);
+
+ // Use explicit id if provided, otherwise fall back to context id
+ // (links the input to the invisible label overlay for click-to-focus).
+ const inputId = props.id ?? context?.inputId;
+
+ return (
+
+ );
+ },
+);
+Input.displayName = "InputGroup.Input";
diff --git a/packages/kumo/src/components/input-group/input-group-suffix.tsx b/packages/kumo/src/components/input-group/input-group-suffix.tsx
new file mode 100644
index 0000000000..ee068722e7
--- /dev/null
+++ b/packages/kumo/src/components/input-group/input-group-suffix.tsx
@@ -0,0 +1,39 @@
+import { forwardRef, type ReactNode } from "react";
+import { cn } from "../../utils/cn";
+import { useInputGroupContext, INPUT_GROUP_SIZE } from "./context";
+
+export interface InputGroupSuffixProps {
+ /** Additional CSS classes. */
+ className?: string;
+ /** Suffix content (e.g., ".workers.dev"). */
+ children?: ReactNode;
+}
+
+/**
+ * Inline suffix that flows seamlessly next to the typed input value.
+ * Input width adjusts automatically via CSS `field-sizing: content`.
+ */
+export const Suffix = forwardRef(
+ ({ className, children }, ref) => {
+ const context = useInputGroupContext("Suffix");
+
+ const size = context?.size ?? "base";
+ const tokens = INPUT_GROUP_SIZE[size];
+
+ return (
+