An animated React input split into multiple editable, customizable segments. Great for codes, dates and structured values.
import SplitInput from "@/components/split-input";
const segmentClassName =
"bg-muted-foreground/10 text-foreground has-invalid:ring-1 has-invalid:ring-error has-invalid:text-error";
const segmentLabelClassName = "peer-invalid:text-error";
const splitButtonClassName =
"bg-muted-foreground/10 text-foreground data-[disabled=true]:text-muted-foreground";
function SplitInputDemo() {
const [values, setValues] = useState({
hours: 2,
minutes: 30,
});
const isHourValid = values.hours > 0 && values.hours < 24;
const isMinutesValid = values.minutes > 0 && values.minutes < 60;
const isTimeInvalid = !(isHourValid && isMinutesValid);
return (
<SplitInput
buttonClassName={splitButtonClassName}
canClose={!isTimeInvalid}
layoutId="timer-demo"
onValueChange={({ valueKey, value }) =>
setValues((prev) => ({ ...prev, [valueKey]: value }))
}
>
<SplitInput.Item
aria-invalid={!isHourValid}
className={segmentClassName}
emitWhenInvalid
label="Hr."
labelClassName={segmentLabelClassName}
max={23}
min={1}
required
type="number"
value={values.hours}
valueKey="hours"
/>
<SplitInput.Item
aria-invalid={!isMinutesValid}
className={segmentClassName}
emitWhenInvalid
label="Min."
labelClassName={segmentLabelClassName}
max={59}
min={1}
required
type="number"
value={values.minutes}
valueKey="minutes"
/>
</SplitInput>
);
}import SplitInput from "@/components/split-input";
const segmentClassName =
"bg-muted-foreground/10 text-foreground has-invalid:ring-1 has-invalid:ring-error";
const segmentLabelClassName = "peer-invalid:text-error";
const splitButtonClassName =
"bg-muted-foreground/10 text-foreground data-[disabled=true]:text-muted-foreground";
function SplitInputDemo() {
const [values, setValues] = useState({
tlRadius: 0,
trRadius: 0,
blRadius: 0,
brRadius: 0,
});
const isBorderRadiusInvalid = values.tlRadius < 0 || values.tlRadius > 100 || values.trRadius < 0 || values.trRadius > 100 || values.blRadius < 0 || values.blRadius > 100 || values.brRadius < 0 || values.brRadius > 100;
return (
<SplitInput
btnRender={({ isEditing }) => (isEditing ? "Save" : "Edit")}
buttonClassName={splitButtonClassName}
layoutId="radius-demo"
onValueChange={({ valueKey, value }) =>
setValues((prev) => ({ ...prev, [valueKey]: value }))
}
canClose={!isBorderRadiusInvalid}
>
{Object.entries(values).map(([key, value]) => (
<SplitInput.Item
key={key}
label={key.slice(0, 2)}
valueKey={key}
type="number"
required
min={0}
max={100}
value={value}
className={segmentClassName}
aria-invalid={value < 0 || value > 100}
labelClassName={segmentLabelClassName}
/>
))}
</SplitInput>
);
}npm install lucide-react motionbun add lucide-react motiondeno add lucide-react motionpnpm add lucide-react motionyarn add lucide-react motionCopy and paste the following code into your project.
"use client";
import { AnimatePresence, LayoutGroup, motion, MotionConfig } from "motion/react";
import { CheckIcon, PencilIcon } from "lucide-react";
import {
Children,
cloneElement,
createContext,
isValidElement,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
type SplitInputContextValue = {
isEditing: boolean;
borderRadiusPx: number;
totalSegments: number;
layoutId: string;
registerInput: (index: number, node: HTMLInputElement | null) => void;
onValueChange: (payload: { valueKey: string; value: string }) => void;
};
const SplitInputContext = createContext<SplitInputContextValue | null>(null);
function useSplitInput() {
const ctx = useContext(SplitInputContext);
if (!ctx) {
throw new Error("SplitInput.Item must be used inside <SplitInput>");
}
return ctx;
}
type SplitInputProps = {
borderRadiusPx?: number;
/**
* Emit the changed field every time any value changes. Use this to sync parent state.
*/
onValueChange: ({ valueKey, value }: { valueKey: string; value: string }) => void;
/**
* Index of the input to focus when entering edit mode. Defaults to `0`.
*/
editFocusIndex?: number;
/**
* Class name for the button.
*
* @example
* text-white data-[disabled=true]:text-gray-500
*/
buttonClassName?: string;
/**
* Whether the input can be opened. Defaults to `true`.
*/
canOpen?: boolean;
/**
* Whether the input can be closed. Defaults to `true`.
*/
canClose?: boolean;
/**
* Unique id so multiple SplitInputs on the same page don't share layout animations.
* Defaults to a React `useId()` value.
*/
layoutId?: string;
/**
* Optional render function for the action button's content. Receives the current
* state (open/closed). When provided, replaces the default icon markup.
*/
btnRender?: (state: { isEditing: boolean }) => ReactNode;
children: ReactNode;
};
export type SplitInputItemProps = {
label: string;
valueKey: string;
/**
* Whether to emit the value via `onValueChange` even when the input fails
* its native validation constraints (`pattern`, `min`, `max`, `required`, …).
* Defaults to `false` — invalid values are not emitted.
*/
emitWhenInvalid?: boolean;
/**
* Class name for the label.
*
* @example
* text-white peer-invalid:text-red-500
*/
labelClassName?: string;
} & Omit<
React.ComponentProps<"input">,
"onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
>;
type SplitInputItemInternalProps = SplitInputItemProps & {
index: number;
};
function segmentRadius(index: number, total: number, isEditing: boolean, r: number) {
const isFirst = index === 0;
const isLast = index === total - 1;
if (isEditing) {
return {
borderTopLeftRadius: r,
borderBottomLeftRadius: r,
borderTopRightRadius: r,
borderBottomRightRadius: r,
};
}
return {
borderTopLeftRadius: isFirst ? r : 0,
borderBottomLeftRadius: isFirst ? r : 0,
borderTopRightRadius: isLast ? r : 0,
borderBottomRightRadius: isLast ? r : 0,
};
}
function clampFocusIndex(index: number, length: number) {
if (length <= 0) return 0;
return Math.min(Math.max(0, index), length - 1);
}
function isSplitInputItemElement(
child: ReactNode
): child is ReactElement<SplitInputItemInternalProps> {
return isValidElement(child) && child.type === SplitInputItem;
}
function SplitInputItem({
label,
valueKey,
index,
onChange,
emitWhenInvalid = false,
/**
* IMPORTANT: For this component, the className prop is applied to the container element, not the input element.
*/
className,
labelClassName,
...inputProps
}: SplitInputItemInternalProps) {
const { isEditing, borderRadiusPx, totalSegments, layoutId, registerInput, onValueChange } =
useSplitInput();
const isFirst = index === 0;
return (
<motion.div
layout
layoutId={`${layoutId}-${index}-${valueKey}`}
data-disabled={!isEditing}
className={cn(
"flex items-center gap-1 bg-[#f3f5f6] py-3 pr-3 font-medium h-full text-black",
(isEditing || isFirst) && "pl-3",
className
)}
initial={false}
animate={segmentRadius(index, totalSegments, isEditing, borderRadiusPx)}
aria-label={`${label} input box`}
>
<motion.input
ref={(node) => registerInput(index, node)}
animate={{
width: isEditing ? "2.5rem" : "auto",
}}
readOnly={!isEditing}
tabIndex={isEditing ? 0 : -1}
layout
layoutId={`${layoutId}-${valueKey}-value`}
className={cn(
"peer will-change-transform block bg-transparent p-0 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
)}
style={{
fieldSizing: "content",
width: "auto",
minWidth: "1ch",
}}
size={1}
{...inputProps}
onChange={(e) => {
if (emitWhenInvalid || e.target.validity.valid) {
onValueChange({
valueKey,
value: e.target.value,
});
}
onChange?.(e);
}}
/>
<motion.span
layout
layoutId={`${layoutId}-${valueKey}-label`}
className={cn("text-[#ababab]", isEditing && "mr-1 ml-1", labelClassName)}
data-disabled={!isEditing}
>
{label}
</motion.span>
</motion.div>
);
}
SplitInputItem.displayName = "SplitInput.Item";
function SplitInputRoot({
borderRadiusPx = 8,
onValueChange,
editFocusIndex = 0,
buttonClassName,
canOpen = true,
canClose = true,
layoutId: layoutIdProp,
btnRender,
children,
}: SplitInputProps) {
const [isEditing, setIsEditing] = useState(false);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const actionButtonRef = useRef<HTMLButtonElement>(null);
const wasEditingRef = useRef(false);
const generatedId = useId();
const layoutId = layoutIdProp ?? generatedId;
const items = useMemo(
() => Children.toArray(children).filter(isSplitInputItemElement),
[children]
);
const itemCount = items.length;
const totalSegments = itemCount + 1;
const registerInput = (index: number, node: HTMLInputElement | null) => {
inputRefs.current[index] = node;
};
const open = () => {
if (!canOpen) return;
setIsEditing(true);
};
const close = () => {
if (!canClose) return;
setIsEditing(false);
};
const toggle = () => {
if (isEditing) close();
else open();
};
useEffect(() => {
if (isEditing) {
const index = clampFocusIndex(editFocusIndex, itemCount);
const input = inputRefs.current[index];
input?.focus();
input?.select();
wasEditingRef.current = true;
return;
}
if (!wasEditingRef.current) return;
for (const input of inputRefs.current) {
if (input && document.activeElement === input) {
input.blur();
}
}
actionButtonRef.current?.focus();
wasEditingRef.current = false;
}, [isEditing, editFocusIndex, itemCount]);
useEffect(() => {
if (!isEditing) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
if (!canClose) return;
setIsEditing(false);
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isEditing, canClose]);
const ctx = useMemo<SplitInputContextValue>(
() => ({
isEditing,
borderRadiusPx,
totalSegments,
layoutId,
registerInput,
onValueChange,
}),
[isEditing, borderRadiusPx, totalSegments, layoutId, onValueChange]
);
let itemIndex = 0;
return (
<SplitInputContext.Provider value={ctx}>
<MotionConfig transition={{ type: "spring", duration: 0.4, bounce: 0.3 }}>
<LayoutGroup id={layoutId}>
<motion.div
layout
className={cn("flex items-center h-12", isEditing && "gap-2")}
aria-label="Split input"
>
{Children.map(children, (child) => {
if (!isSplitInputItemElement(child)) return child;
const index = itemIndex++;
return cloneElement(child, { index });
})}
<motion.button
ref={actionButtonRef}
layout
layoutId={`${layoutId}-action`}
type="button"
aria-label={isEditing ? "Confirm" : "Edit"}
aria-pressed={isEditing}
data-is-editing={isEditing}
className={cn(
"grid cursor-pointer place-items-center bg-[#f3f5f6] px-4 h-full text-[#101010] data-[disabled=true]:text-[#ababab]",
buttonClassName
)}
initial={false}
animate={segmentRadius(itemCount, totalSegments, isEditing, borderRadiusPx)}
onClick={toggle}
>
{btnRender ? (
btnRender({ isEditing })
) : (
<AnimatePresence mode="popLayout" initial={false}>
{isEditing ? (
<motion.span
key="check"
initial={{ scale: 0.8, filter: "blur(4px)" }}
animate={{ scale: 1, filter: "blur(0px)" }}
exit={{ scale: 0.8, filter: "blur(4px)" }}
transition={{ duration: 0.2 }}
>
<CheckIcon className="size-4" />
</motion.span>
) : (
<motion.span
key="pencil"
initial={{ scale: 0.8, filter: "blur(4px)" }}
animate={{ scale: 1, filter: "blur(0px)" }}
exit={{ scale: 0.8, filter: "blur(4px)" }}
transition={{ duration: 0.2 }}
>
<PencilIcon className="size-4" />
</motion.span>
)}
</AnimatePresence>
)}
</motion.button>
</motion.div>
</LayoutGroup>
</MotionConfig>
</SplitInputContext.Provider>
);
}
const SplitInput = Object.assign(SplitInputRoot, {
Item: SplitInputItem as React.FC<SplitInputItemProps>,
});
export default SplitInput;import SplitInput from "@/components/split-input";const [values, setValues] = useState({ hours: "2", minutes: "30" });
<SplitInput
onValueChange={({ valueKey, value }) =>
setValues((prev) => ({ ...prev, [valueKey]: value }))
}
>
<SplitInput.Item label="Hr." valueKey="hours" value={values.hours} />
<SplitInput.Item label="Min." valueKey="minutes" value={values.minutes} />
</SplitInput>SplitInput.Item extends native <input> props. You can pass type, min, max, required, placeholder, aria-*, etc.
<SplitInput.Item
label="Hr."
valueKey="hours"
type="number"
min={0}
max={23}
required
value={values.hours}
placeholder="0"
/>It's managed with native HTML aria-invalid, plus Tailwind peer / has-invalid classes for feedback. You can block confirm until values are valid with canClose.
const invalid = Number(values.hours) > 23;
<SplitInput
onValueChange={...}
canClose={!invalid}
>
<SplitInput.Item
label="Hr."
valueKey="hours"
type="number"
max={23}
value={values.hours}
aria-invalid={invalid}
className="has-invalid:ring-1 has-invalid:ring-red-500"
labelClassName="peer-invalid:text-red-500"
/>
</SplitInput>By default, values that fail validation (pattern, min, max, required, …) are not emitted via onValueChange. Set emitWhenInvalid to true to emit them anyway.
<SplitInput.Item
label="Hr."
valueKey="hours"
max={23}
min={1}
value={values.hours}
emitWhenInvalid
/>{/* Focus the second segment on edit */}
<SplitInput editFocusIndex={1} onValueChange={...}>
...
</SplitInput>
{/* Unique layoutId when multiple instances share a page */}
<SplitInput layoutId="timer" onValueChange={...}>
...
</SplitInput>Use btnRender to fully replace the default pencil / check icons of the action button. The render function receives the current isEditing flag, so you can swap icons, labels, or animations based on state.
<SplitInput
onValueChange={...}
btnRender={({ isEditing }) => (
<span className="text-xs font-medium">
{isEditing ? "Save" : "Edit"}
</span>
)}
>
...
</SplitInput>| Prop | Type | Default | Description |
|---|---|---|---|
| onValueChange | ({ valueKey, value }: { valueKey: string; value: string }) => void | — | Called whenever any segment value changes. |
| children | ReactNode | — | SplitInput.Item elements. |
| borderRadiusPx | number | 8 | Corner radius (px) for segments and the action button. |
| editFocusIndex | number | 0 | Segment index focused when entering edit mode. |
| canOpen | boolean | true | Whether edit mode can be opened. |
| canClose | boolean | true | Whether edit mode can be closed (confirm / Escape). |
| layoutId | string | useId() | Unique id for layout animations across instances. |
| buttonClassName | string | — | Class name for the edit / confirm button. |
| btnRender | (state: { isEditing: boolean }) => ReactNode | — | Optional render function for the action button content. Receives the current state; when provided, replaces the default pencil / check icons. |
Extends all native <input> props (except Motion-conflicting onDrag, onDragStart, onDragEnd, onAnimationStart).
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Label shown next to the value. |
| valueKey | string | — | Key passed to onValueChange for this segment. |
| className | string | — | Applied to the segment container, not the <input>. |
| labelClassName | string | — | Class name for the label. |
| emitWhenInvalid | boolean | false | By default, values that fail native validation (pattern, min, max, required, …) are not emitted via onValueChange. Set to true to emit them anyway. |
| ...inputProps | ComponentProps<"input"> | — | Forwarded to the underlying <input> (type, value, min, max, required, aria-invalid, onChange, …). |