Back to library
inputs
Smooth Input
An input with a spring-animated caret that glides between characters, with password masking, scroll tracking and reduced-motion support.
Inspired by Skiper UI · View source
Preview
Enter your info below
Copy for AI
Copy for Lovable is the fastest way to move this component into another Lovable project — it copies the full prompt with dependencies, files, and a usage example. Copy all files gives you the raw source if you want to paste it manually.
Installation
npx shadcn@latest add @skiper-ui/skiper106Paste into a terminal — or paste into a Lovable chat and I'll run it for you.
Source files
components/ui/smooth-input.tsx
"use client";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
} from "framer-motion";
import React, {
type ComponentPropsWithoutRef,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
const inputWrapperClassName = cn(
"bg-muted has-[:focus-visible]:outline-ring relative w-full max-w-[420px] rounded-2xl p-4",
"has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2",
);
const inputClassName =
"w-full bg-transparent outline-none placeholder:text-foreground/40";
type InputFieldProps = ComponentPropsWithoutRef<"input"> & {
wrapperClassName?: string;
};
type SmoothInputType = "text" | "password";
type SmoothInputProps = Omit<InputFieldProps, "type"> & {
type?: SmoothInputType;
/** Caret spring config. */
spring?: { stiffness: number; damping: number; mass: number };
/** Font size in px for the input text. */
fontSize?: number;
};
const Input = ({ className, wrapperClassName, ...props }: InputFieldProps) => {
return (
<div className={cn(inputWrapperClassName, wrapperClassName)}>
<input className={cn(inputClassName, className)} {...props} />
</div>
);
};
const getPasswordChar = () =>
typeof navigator !== "undefined" && /firefox|fxios/i.test(navigator.userAgent)
? "\u25CF"
: "\u2022";
const SmoothInput = ({
className,
wrapperClassName,
value,
defaultValue,
onChange,
onBlur,
type = "text",
placeholder = "smooth input",
spring = { stiffness: 500, damping: 30, mass: 0.5 },
fontSize = 24,
style,
...props
}: SmoothInputProps) => {
const [internalValue, setInternalValue] = useState(defaultValue ?? "");
const caretX = useMotionValue(0);
const caretOpacity = useMotionValue(0);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const prefersReducedMotion = useReducedMotion();
const isControlled = value !== undefined;
const springCaretX = useSpring(
caretX,
prefersReducedMotion
? { stiffness: 10000, damping: 100, mass: 0.1 }
: spring,
);
const inputValue = isControlled ? String(value) : internalValue;
const syncMeasureSpan = () => {
const input = inputRef.current;
const measureSpan = measureRef.current;
if (!input || !measureSpan) return;
const styles = window.getComputedStyle(input);
const isPassword = input.type === "password";
const passwordChar = getPasswordChar();
let size = styles.fontSize;
if (
passwordChar === "\u2022" &&
isPassword &&
!/chrome|chromium|crios/i.test(navigator.userAgent)
) {
size = `${parseFloat(size) + 6.25}px`;
}
measureSpan.style.font = `${styles.fontStyle} ${styles.fontWeight} ${size} ${styles.fontFamily}`;
measureSpan.style.letterSpacing = styles.letterSpacing;
measureSpan.style.fontFeatureSettings = styles.fontFeatureSettings;
measureSpan.style.fontVariationSettings = styles.fontVariationSettings;
};
const measurePrefixWidth = (text: string) => {
const input = inputRef.current;
const measureSpan = measureRef.current;
if (!input || !measureSpan) return null;
syncMeasureSpan();
measureSpan.textContent = text;
const paddingLeft =
parseFloat(window.getComputedStyle(input).paddingLeft) || 0;
return text.length > 0
? measureSpan.offsetWidth + paddingLeft
: paddingLeft - 1;
};
const scrollCaretIntoView = (
target: HTMLInputElement,
absoluteWidth: number,
) => {
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft) || 0;
const paddingRight = parseFloat(styles.paddingRight) || 0;
const maxScroll = Math.max(0, target.scrollWidth - target.clientWidth);
const visibleRight = target.scrollLeft + target.clientWidth - paddingRight;
const visibleLeft = target.scrollLeft + paddingLeft;
if (absoluteWidth > visibleRight) {
target.scrollLeft = Math.min(
absoluteWidth - target.clientWidth + paddingRight,
maxScroll,
);
return;
}
if (absoluteWidth < visibleLeft) {
target.scrollLeft = Math.max(0, absoluteWidth - paddingLeft);
}
};
const getCaretIndex = (target: HTMLInputElement) => {
const selectionStart = target.selectionStart ?? 0;
const selectionEnd = target.selectionEnd ?? 0;
if (selectionStart === selectionEnd) return selectionStart;
return target.selectionDirection === "backward"
? selectionStart
: selectionEnd;
};
const updateCaretFromInput = (target: HTMLInputElement) => {
const selectionStart = target.selectionStart ?? 0;
const selectionEnd = target.selectionEnd ?? 0;
const hasSelection = selectionStart !== selectionEnd;
const caretIndex = getCaretIndex(target);
const isPassword = target.type === "password";
const textBeforeCaret = isPassword
? getPasswordChar().repeat(caretIndex)
: target.value.slice(0, caretIndex);
const absoluteWidth = measurePrefixWidth(textBeforeCaret);
if (absoluteWidth === null) return;
scrollCaretIntoView(target, absoluteWidth);
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft) || 0;
const paddingRight = parseFloat(styles.paddingRight) || 0;
const caretPosition = absoluteWidth - target.scrollLeft;
const minX = paddingLeft - 1;
const maxX = target.clientWidth - paddingRight;
const isCaretVisible = caretPosition >= minX && caretPosition <= maxX + 1;
caretX.set(Math.min(caretPosition, maxX));
if (!isCaretVisible || hasSelection) {
caretOpacity.set(0);
return;
}
caretOpacity.set(1);
};
const updateCaretRef = useRef(updateCaretFromInput);
updateCaretRef.current = updateCaretFromInput;
const caretOpacityRef = useRef(caretOpacity);
caretOpacityRef.current = caretOpacity;
useEffect(() => {
const input = inputRef.current;
if (input && document.activeElement === input) {
updateCaretRef.current(input);
}
}, [inputValue]);
useEffect(() => {
const input = inputRef.current;
if (input && document.activeElement === input) {
updateCaretRef.current(input);
}
}, [type, fontSize]);
useEffect(() => {
const input = inputRef.current;
const container = containerRef.current;
if (!input || !container) return;
const updateCaretIfFocused = () => {
if (document.activeElement === input) {
updateCaretRef.current(input);
}
};
const handleSelectionChange = () => {
if (document.activeElement !== input) return;
requestAnimationFrame(updateCaretIfFocused);
};
document.addEventListener("selectionchange", handleSelectionChange);
document.fonts.addEventListener("loadingdone", updateCaretIfFocused);
void document.fonts.ready.then(updateCaretIfFocused);
input.addEventListener("scroll", updateCaretIfFocused);
const resizeObserver = new ResizeObserver(updateCaretIfFocused);
resizeObserver.observe(container);
updateCaretIfFocused();
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
document.fonts.removeEventListener("loadingdone", updateCaretIfFocused);
input.removeEventListener("scroll", updateCaretIfFocused);
resizeObserver.disconnect();
};
}, []);
return (
<div className={cn(inputWrapperClassName, wrapperClassName)}>
<div
ref={containerRef}
className="relative grid grid-cols-1 p-0"
style={{ caretColor: "transparent", fontSize }}
>
<input
{...props}
ref={inputRef}
type={type}
placeholder={placeholder}
className={cn(
inputClassName,
"col-start-1 col-end-2 row-start-1 row-end-2 text-inherit",
className,
)}
style={style}
value={inputValue}
onChange={(e) => {
if (!isControlled) setInternalValue(e.target.value);
onChange?.(e);
requestAnimationFrame(() => {
updateCaretRef.current(e.target);
});
}}
onBlur={(e) => {
caretOpacityRef.current.set(0);
onBlur?.(e);
}}
/>
<span
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute top-0 left-0 whitespace-pre"
/>
<motion.div
className="bg-primary pointer-events-none col-start-1 col-end-2 row-start-1 row-end-2 h-[0.9em] w-0.5 self-center"
style={{ x: springCaretX, opacity: caretOpacity }}
/>
</div>
</div>
);
};
export { Input, SmoothInput };
export default SmoothInput;
Dependencies
framer-motion