Inline Edit
Click-to-edit text that turns into an input or auto-growing textarea in the exact same box (no layout jump) on click, Enter or F2, saves on Enter or blur and cancels on Escape, blocks saves with a validate message and shake, and runs async onSave with a pending spinner, rollback plus error on rejection, and a drawn check-mark flash on success.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/inline-edit
import {
AnimatePresence,
animate,
motion,
useReducedMotion,
} from "motion/react";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
} from "react";
import { Loader } from "@/components/motion/loader";
import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type InlineEditClassNames = {
root?: string;
display?: string;
input?: string;
placeholder?: string;
errorMessage?: string;
};
export interface InlineEditProps {
/** Accessible name of the field ("Name", "Bio"); also used in the edit hint. */
label: string;
value?: string;
defaultValue?: string;
/**
* Commit handler. Return a promise to show a pending spinner; the shown value
* rolls back (with a shake and the rejection message) if it rejects.
* Controlled callers update `value` here.
*/
onSave?: (next: string) => unknown;
onCancel?: () => void;
onEditingChange?: (editing: boolean) => void;
/** Return an error message to block the save and keep the editor open. */
validate?: (next: string) => string | null | undefined | false;
/** Edit in an auto-growing textarea; Enter saves, Shift+Enter adds a line. */
multiline?: boolean;
placeholder?: string;
maxLength?: number;
/** Select the whole text when the editor opens (default) or put the caret at the end. */
selectOnEdit?: boolean;
disabled?: boolean;
className?: string;
classNames?: InlineEditClassNames;
}
type Status = "idle" | "pending" | "saved";
const SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] };
const SAVED_FLASH_MS = 1400;
function messageOf(reason: unknown) {
if (reason instanceof Error && reason.message) return reason.message;
if (typeof reason === "string" && reason) return reason;
return "Couldn't save. Try again.";
}
function isThenable(value: unknown): value is PromiseLike<unknown> {
return (
typeof value === "object" &&
value !== null &&
typeof (value as PromiseLike<unknown>).then === "function"
);
}
export function InlineEdit({
label,
value: valueProp,
defaultValue,
onSave,
onCancel,
onEditingChange,
validate,
multiline = false,
placeholder = "Empty",
maxLength,
selectOnEdit = true,
disabled,
className,
classNames,
}: InlineEditProps) {
const id = useId();
const reduce = useReducedMotion();
const controlled = valueProp !== undefined;
const [internal, setInternal] = useState(defaultValue ?? "");
const value = controlled ? (valueProp ?? "") : internal;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [status, setStatus] = useState<Status>("idle");
const [pendingValue, setPendingValue] = useState("");
const [error, setError] = useState<string | null>(null);
// Refs gate commit/cancel so the blur fired while the editor unmounts can't
// save a second time.
const editingRef = useRef(false);
const restoreFocusRef = useRef(false);
const saveIdRef = useRef(0);
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const boxRef = useRef<HTMLDivElement>(null);
const displayRef = useRef<HTMLButtonElement>(null);
const fieldRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (flashTimer.current) clearTimeout(flashTimer.current);
};
}, []);
const shake = useCallback(() => {
if (!boxRef.current || reduce) return;
animate(boxRef.current, SHAKE, { duration: 0.45 });
}, [reduce]);
const setEditingState = (next: boolean) => {
editingRef.current = next;
setEditing(next);
onEditingChange?.(next);
};
// Focus the editor as it opens; return focus to the text after a
// keyboard commit or cancel (not after blur — that would steal focus).
useEffect(() => {
if (editing) {
const field = fieldRef.current;
if (!field) return;
field.focus();
if (selectOnEdit) field.select();
else field.setSelectionRange(field.value.length, field.value.length);
} else if (restoreFocusRef.current) {
restoreFocusRef.current = false;
displayRef.current?.focus();
}
}, [editing, selectOnEdit]);
const startEditing = () => {
if (disabled || editingRef.current || status === "pending") return;
if (flashTimer.current) clearTimeout(flashTimer.current);
setStatus("idle");
setDraft(value);
setError(null);
setEditingState(true);
};
const cancel = () => {
if (!editingRef.current) return;
restoreFocusRef.current = true;
setDraft(value);
setError(null);
setEditingState(false);
onCancel?.();
};
const flashSaved = () => {
setStatus("saved");
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => {
if (mountedRef.current) setStatus("idle");
}, SAVED_FLASH_MS);
};
const commit = (via: "key" | "blur") => {
if (!editingRef.current) return;
const next = draft;
if (next === value) {
if (via === "key") restoreFocusRef.current = true;
setError(null);
setEditingState(false);
return;
}
const invalid = validate?.(next);
if (invalid) {
setError(invalid);
shake();
return;
}
if (via === "key") restoreFocusRef.current = true;
setError(null);
setEditingState(false);
const result = onSave?.(next);
if (!isThenable(result)) {
if (!controlled) setInternal(next);
flashSaved();
return;
}
const saveId = ++saveIdRef.current;
setPendingValue(next);
setStatus("pending");
Promise.resolve(result).then(
() => {
if (!mountedRef.current || saveId !== saveIdRef.current) return;
if (!controlled) setInternal(next);
flashSaved();
},
(reason: unknown) => {
if (!mountedRef.current || saveId !== saveIdRef.current) return;
setStatus("idle");
setError(messageOf(reason));
shake();
},
);
};
const onFieldKeyDown = (
event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
cancel();
} else if (event.key === "Enter" && !(multiline && event.shiftKey)) {
event.preventDefault();
commit("key");
}
};
const shown = status === "pending" ? pendingValue : value;
const errorId = `${id}-error`;
// Display and editor share this box model exactly, so swapping them never
// moves the text. The -mx-2 hangs the padding outside so text aligns with
// surrounding content.
const textBox = cn(
"w-full rounded-lg px-2 py-1 pr-8 text-left",
multiline ? "whitespace-pre-wrap break-words" : "",
);
const iconSlot = cn(
"pointer-events-none absolute right-2 z-10 flex size-4 items-center justify-center",
multiline ? "top-[calc(0.25rem+0.5lh)] -translate-y-1/2" : "top-1/2 -translate-y-1/2",
);
const motionIn = reduce
? { opacity: 1 }
: { opacity: 1, scale: 1 };
const motionOut = reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.6 };
return (
<div
data-state={editing ? "editing" : status}
className={cn("relative -mx-2", className, classNames?.root)}
>
<div ref={boxRef} className="group relative">
{/* Field surface: fades and settles in under the text while editing. */}
<motion.span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 rounded-lg border bg-background ring-2",
error
? "border-destructive ring-destructive/25"
: "border-foreground/30 ring-ring/40",
)}
initial={false}
animate={
editing
? motionIn
: reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.98 }
}
transition={{ duration: editing ? 0.2 : 0.12, ease: EASE_OUT }}
/>
{editing ? (
multiline ? (
<div className="relative grid">
<span
aria-hidden="true"
className={cn(
textBox,
"invisible [grid-area:1/1]",
classNames?.input,
)}
>
{`${draft} `}
</span>
<textarea
ref={fieldRef}
aria-label={label}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
rows={1}
value={draft}
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => {
setDraft(e.target.value);
if (error) setError(null);
}}
onKeyDown={onFieldKeyDown}
onBlur={() => commit("blur")}
className={cn(
textBox,
"relative resize-none overflow-hidden bg-transparent text-foreground caret-foreground outline-none [grid-area:1/1] placeholder:text-muted-foreground/60",
classNames?.input,
)}
/>
</div>
) : (
<input
ref={fieldRef}
aria-label={label}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
value={draft}
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => {
setDraft(e.target.value);
if (error) setError(null);
}}
onKeyDown={onFieldKeyDown}
onBlur={() => commit("blur")}
className={cn(
textBox,
"relative block bg-transparent text-foreground caret-foreground outline-none placeholder:text-muted-foreground/60",
classNames?.input,
)}
/>
)
) : (
<button
ref={displayRef}
type="button"
disabled={disabled}
aria-describedby={error ? errorId : undefined}
onClick={startEditing}
onKeyDown={(event) => {
if (event.key === "F2") {
event.preventDefault();
startEditing();
}
}}
className={cn(
textBox,
"relative block cursor-text transition-[background-color,opacity] duration-150",
"outline-none hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring/50",
!multiline && "truncate",
status === "pending" && "opacity-70",
disabled && "cursor-not-allowed hover:bg-transparent",
classNames?.display,
)}
>
{shown ? (
shown
) : (
<span
className={cn(
"text-muted-foreground/70 italic",
classNames?.placeholder,
)}
>
{placeholder}
</span>
)}
<span className="sr-only">, edit {label}</span>
</button>
)}
<span className={iconSlot}>
<AnimatePresence initial={false} mode="popLayout">
{status === "pending" ? (
<motion.span
key="pending"
className="flex"
initial={motionOut}
animate={motionIn}
exit={motionOut}
transition={SPRING_SWAP}
>
<Loader
variant="spinner"
size={14}
label="Saving"
className="text-muted-foreground"
/>
</motion.span>
) : status === "saved" ? (
<motion.span
key="saved"
className="flex size-4 items-center justify-center rounded-full bg-success text-background"
initial={motionOut}
animate={motionIn}
exit={{ opacity: 0 }}
transition={SPRING_PRESS}
>
<svg viewBox="0 0 24 24" fill="none" className="size-3" aria-hidden="true">
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.05 }}
/>
</svg>
</motion.span>
) : !editing && !disabled ? (
<motion.span
key="pencil"
className="flex text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<span className="flex opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
aria-hidden="true"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</span>
</motion.span>
) : null}
</AnimatePresence>
</span>
</div>
<span className="sr-only" aria-live="polite">
{status === "pending" ? "Saving" : status === "saved" ? "Saved" : ""}
</span>
<AnimatePresence initial={false}>
{error ? (
<motion.p
id={errorId}
role="alert"
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: -4, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: -4, filter: "blur(4px)" }
}
transition={{ duration: 0.2 }}
className={cn(
"px-2 pt-1 text-xs text-destructive",
classNames?.errorMessage,
)}
>
{error}
</motion.p>
) : null}
</AnimatePresence>
</div>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type LoaderVariant =
| "spinner"
| "dots"
| "bars"
| "dot-matrix"
| "dither"
| "ascii"
| "ascii-line"
| "ascii-braille"
| "ascii-blocks"
| "ascii-bounce"
| "morph"
| "comet"
| "scramble"
| "metaballs"
| "newton"
| "helix"
| "percent";
// Terminal-style frame sets — the loaders CLI AI agents cycle through.
const ASCII_SETS: Record<string, string[]> = {
ascii: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
"ascii-line": ["|", "/", "-", "\\"],
"ascii-braille": ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
"ascii-blocks": ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂"],
"ascii-bounce": ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
};
export interface LoaderProps {
/** Which animation to render. */
variant?: LoaderVariant;
/** Base square size in px. Everything scales from this. */
size?: number;
/** Seconds per animation cycle. */
speed?: number;
/** Accessible label announced to screen readers. */
label?: string;
className?: string;
}
// Reduced motion keeps a calm opacity pulse and drops every transform.
const REDUCED = {
animate: { opacity: [1, 0.4, 1] },
transition: { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity },
};
export function Loader({
variant = "spinner",
size = 32,
speed = 1,
label = "Loading",
className,
}: LoaderProps) {
const reduce = useReducedMotion() ?? false;
return (
<span
role="status"
aria-label={label}
className={cn(
"inline-flex items-center justify-center text-foreground",
className,
)}
>
{variant === "spinner" && <Spinner size={size} speed={speed} reduce={reduce} />}
{variant === "dots" && <Dots size={size} speed={speed} reduce={reduce} />}
{variant === "bars" && <Bars size={size} speed={speed} reduce={reduce} />}
{variant === "dot-matrix" && (
<DotMatrix size={size} speed={speed} reduce={reduce} />
)}
{variant === "dither" && <Dither size={size} speed={speed} reduce={reduce} />}
{ASCII_SETS[variant] && (
<Ascii frames={ASCII_SETS[variant]} size={size} speed={speed} reduce={reduce} />
)}
{variant === "morph" && <Morph size={size} speed={speed} reduce={reduce} />}
{variant === "comet" && <Comet size={size} speed={speed} reduce={reduce} />}
{variant === "scramble" && (
<Scramble size={size} speed={speed} reduce={reduce} />
)}
{variant === "metaballs" && (
<Metaballs size={size} speed={speed} reduce={reduce} />
)}
{variant === "newton" && <Newton size={size} speed={speed} reduce={reduce} />}
{variant === "helix" && <Helix size={size} speed={speed} reduce={reduce} />}
{variant === "percent" && (
<Percent size={size} speed={speed} reduce={reduce} />
)}
<span className="sr-only">{label}</span>
</span>
);
}
interface PartProps {
size: number;
speed: number;
reduce: boolean;
}
function Spinner({ size, speed, reduce }: PartProps) {
const stroke = Math.max(2, size * 0.09);
const r = (size - stroke) / 2;
return (
<motion.svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
animate={reduce ? REDUCED.animate : { rotate: 360 }}
transition={
reduce
? REDUCED.transition
: { duration: speed, ease: "linear", repeat: Infinity }
}
>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeOpacity={0.2}
strokeWidth={stroke}
/>
<path
d={`M ${size / 2} ${size / 2 - r} A ${r} ${r} 0 0 1 ${size / 2 + r} ${size / 2}`}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
/>
</motion.svg>
);
}
function Dots({ size, speed, reduce }: PartProps) {
const dot = size * 0.24;
return (
<span className="flex items-center" style={{ gap: size * 0.14 }}>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="rounded-full bg-current"
style={{ width: dot, height: dot }}
animate={
reduce
? { opacity: [0.4, 1, 0.4] }
: { y: [0, -size * 0.3, 0], opacity: [0.5, 1, 0.5] }
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: i * speed * 0.16,
}}
/>
))}
</span>
);
}
function Ascii({
frames,
size,
speed,
reduce,
}: PartProps & { frames: string[] }) {
const [frame, setFrame] = useState(0);
useEffect(() => {
// Reduced motion slows the cycle rather than stopping it — it's a glyph
// swap, not on-screen movement.
const step = ((reduce ? speed * 2.5 : speed) / frames.length) * 1000;
const id = setInterval(
() => setFrame((f) => (f + 1) % frames.length),
step,
);
return () => clearInterval(id);
}, [frames.length, speed, reduce]);
return (
<span
className="font-mono leading-none tabular-nums"
style={{ fontSize: size, lineHeight: 1 }}
>
{frames[frame % frames.length]}
</span>
);
}
// Each shape is sampled at the same number of points and emitted as an SVG
// path with identical command structure, so framer tweens the `d` attribute
// point-to-point — a real morph, not a snap. (clip-path polygon strings don't
// interpolate reliably in framer, which left the shapes broken.)
const MORPH_POINTS = 24;
function ngonRadius(ang: number, n: number, phase = 0) {
const seg = (2 * Math.PI) / n;
const a = ang - phase;
const local = (((a % seg) + seg) % seg) - seg / 2;
return Math.cos(Math.PI / n) / Math.cos(local);
}
function morphPath(radiusAt: (ang: number) => number) {
const parts: string[] = [];
for (let i = 0; i < MORPH_POINTS; i++) {
const ang = (i / MORPH_POINTS) * 2 * Math.PI - Math.PI / 2;
const r = Math.min(1.05, radiusAt(ang));
const x = (50 + Math.cos(ang) * 46 * r).toFixed(2);
const y = (50 + Math.sin(ang) * 46 * r).toFixed(2);
parts.push(`${i === 0 ? "M" : "L"}${x} ${y}`);
}
return `${parts.join(" ")} Z`;
}
const MORPH_PATHS = [
morphPath(() => 1), // circle
morphPath((a) => ngonRadius(a, 4, Math.PI / 4)), // square
morphPath((a) => ngonRadius(a, 3)), // triangle
morphPath((a) => ngonRadius(a, 6)), // hexagon
morphPath((a) => ngonRadius(a, 4)), // diamond
];
// Each shape appears twice in a row so it fully forms and HOLDS before the
// next morph. Even keyframe spacing then alternates hold / morph segments.
const MORPH_SEQ = [...MORPH_PATHS.flatMap((p) => [p, p]), MORPH_PATHS[0]];
// Rotation and scale only change across the morph segments, staying put on the
// holds, so a settled shape sits still.
const MORPH_ROT = [0, 0, 72, 72, 144, 144, 216, 216, 288, 288, 360];
const MORPH_SCALE = [1, 1, 0.88, 0.88, 1, 1, 0.88, 0.88, 1, 1, 1];
function Morph({ size, speed, reduce }: PartProps) {
return (
<motion.svg
width={size}
height={size}
viewBox="0 0 100 100"
role="img"
animate={
reduce
? { opacity: [1, 0.4, 1] }
: { rotate: MORPH_ROT, scale: MORPH_SCALE }
}
transition={
reduce
? { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity }
: { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }
}
>
<title>Loading</title>
<motion.path
fill="currentColor"
d={MORPH_PATHS[0]}
animate={reduce ? undefined : { d: MORPH_SEQ }}
transition={
reduce
? undefined
: { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }
}
/>
</motion.svg>
);
}
const COMET_TRAIL = [0, 1, 2, 3, 4, 5];
function Comet({ size, speed, reduce }: PartProps) {
const head = size * 0.2;
const r = size / 2 - head / 2;
return (
<span className="relative" style={{ width: size, height: size }}>
<motion.span
className="absolute inset-0"
animate={reduce ? REDUCED.animate : { rotate: 360 }}
transition={
reduce
? REDUCED.transition
: { duration: speed, ease: "linear", repeat: Infinity }
}
>
{COMET_TRAIL.map((i) => {
const scale = 1 - i * 0.13;
const sz = head * scale;
return (
<span
key={i}
className="absolute top-1/2 left-1/2 rounded-full bg-current"
style={{
width: sz,
height: sz,
marginLeft: -sz / 2,
marginTop: -sz / 2,
opacity: 1 - i * 0.16,
transform: `rotate(${-i * 15}deg) translateY(${-r}px)`,
}}
/>
);
})}
</motion.span>
</span>
);
}
const SCRAMBLE_TARGET = "LOADING";
const SCRAMBLE_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>/*#@";
function Scramble({ size, speed, reduce }: PartProps) {
const [text, setText] = useState(SCRAMBLE_TARGET);
useEffect(() => {
if (reduce) {
setText(SCRAMBLE_TARGET);
return;
}
let tick = 0;
const total = SCRAMBLE_TARGET.length + 4;
const id = setInterval(
() => {
const reveal = tick % total;
let s = "";
for (let i = 0; i < SCRAMBLE_TARGET.length; i++) {
s +=
i < reveal
? SCRAMBLE_TARGET[i]
: SCRAMBLE_GLYPHS[
Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)
];
}
setText(s);
tick++;
},
(speed / SCRAMBLE_TARGET.length) * 1000 * 0.55,
);
return () => clearInterval(id);
}, [speed, reduce]);
return (
<span
className="font-mono font-medium tracking-[0.2em] tabular-nums"
style={{ fontSize: size * 0.42 }}
>
{text}
</span>
);
}
function Metaballs({ size, speed, reduce }: PartProps) {
const id = useId().replace(/:/g, "");
return (
<svg width={size} height={size} viewBox="0 0 100 100" role="img">
<title>Loading</title>
<defs>
<filter id={id}>
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="b" />
<feColorMatrix
in="b"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -8"
/>
</filter>
</defs>
<g filter={`url(#${id})`} fill="currentColor">
<motion.circle
cy="50"
r="15"
animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [30, 70, 30] }}
transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}
cx={reduce ? 40 : 30}
/>
<motion.circle
cy="50"
r="15"
animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [70, 30, 70] }}
transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}
cx={reduce ? 60 : 70}
/>
</g>
</svg>
);
}
const NEWTON_BALLS = [0, 1, 2, 3, 4];
function Newton({ size, speed, reduce }: PartProps) {
const d = size * 0.2;
const out = d * 1.1;
// Only the end balls move: the left slides out and back on the first half,
// then the right on the second half — the impact appears to jump the three
// still middle balls. Pure horizontal slide, no swing, no strings.
const moves: Record<number, { x: number[]; times: number[] }> = {
0: { x: [0, -out, 0, 0], times: [0, 0.28, 0.5, 1] },
4: { x: [0, 0, out, 0], times: [0, 0.5, 0.78, 1] },
};
return (
<span className="flex items-center justify-center" style={{ height: d }}>
{NEWTON_BALLS.map((i) => {
const move = moves[i];
return (
<motion.span
key={i}
className="rounded-full bg-current"
style={{ width: d, height: d }}
animate={reduce || !move ? undefined : { x: move.x }}
transition={
reduce || !move
? undefined
: {
duration: speed * 1.5,
ease: EASE_IN_OUT,
repeat: Infinity,
times: move.times,
}
}
/>
);
})}
</span>
);
}
function Helix({ size, speed, reduce }: PartProps) {
const rows = 7;
const dot = size * 0.14;
const amp = size * 0.32;
return (
<span className="relative" style={{ width: size, height: size }}>
{Array.from({ length: rows }, (_, r) => {
const top = (r / (rows - 1)) * (size - dot);
const delay = (r / rows) * speed;
return (
<span key={`row-${top}`}>
<motion.span
className="absolute rounded-full bg-current"
style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}
animate={
reduce
? { opacity: [0.4, 1, 0.4] }
: {
x: [amp, -amp, amp],
scale: [1, 0.5, 1],
opacity: [1, 0.45, 1],
}
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay,
}}
/>
<motion.span
className="absolute rounded-full bg-current"
style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}
animate={
reduce
? { opacity: [0.4, 1, 0.4] }
: {
x: [-amp, amp, -amp],
scale: [0.5, 1, 0.5],
opacity: [0.45, 1, 0.45],
}
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay,
}}
/>
</span>
);
})}
</span>
);
}
function Percent({ size, speed, reduce }: PartProps) {
const [p, setP] = useState(0);
useEffect(() => {
const dur = (reduce ? speed * 2 : speed) * 1000;
const start = { t: 0 };
const tickMs = 40;
const id = setInterval(() => {
start.t += tickMs;
const next = Math.min(100, Math.round((start.t / dur) * 100));
setP(next);
if (next >= 100) start.t = 0;
}, tickMs);
return () => clearInterval(id);
}, [speed, reduce]);
return (
<span
className="flex flex-col items-center"
style={{ gap: size * 0.14, width: size * 1.4 }}
>
<span
className="font-mono font-medium tabular-nums"
style={{ fontSize: size * 0.42, lineHeight: 1 }}
>
{p}%
</span>
<span
className="w-full overflow-hidden rounded-full bg-current/15"
style={{ height: Math.max(3, size * 0.1) }}
>
<span
className="block h-full rounded-full bg-current"
style={{ width: `${p}%` }}
/>
</span>
</span>
);
}
function Bars({ size, speed, reduce }: PartProps) {
const bar = size * 0.16;
return (
<span className="flex items-center" style={{ gap: size * 0.1, height: size }}>
{[0, 1, 2, 3].map((i) => (
<motion.span
key={i}
className="rounded-full bg-current"
style={{ width: bar, height: size, originY: 1 }}
animate={
reduce ? { opacity: [0.4, 1, 0.4] } : { scaleY: [0.3, 1, 0.3] }
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: i * speed * 0.12,
}}
/>
))}
</span>
);
}
function DotMatrix({ size, speed, reduce }: PartProps) {
const n = 3;
const gap = size * 0.14;
const dot = (size - gap * (n - 1)) / n;
const cells = Array.from({ length: n * n }, (_, idx) => idx);
return (
<span
className="grid"
style={{
gap,
gridTemplateColumns: `repeat(${n}, ${dot}px)`,
}}
>
{cells.map((idx) => {
const x = idx % n;
const y = Math.floor(idx / n);
// Diagonal wave: cells light in order of their distance from the corner.
const delay = ((x + y) / (2 * (n - 1))) * speed;
return (
<motion.span
key={idx}
className="rounded-full bg-current"
style={{ width: dot, height: dot }}
animate={
reduce
? { opacity: [0.3, 1, 0.3] }
: { opacity: [0.2, 1, 0.2], scale: [0.7, 1, 0.7] }
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay,
}}
/>
);
})}
</span>
);
}
// Ordered Bayer 4x4 matrix — the classic dithering threshold pattern. Cells
// light in this order, so the fill shimmers like a dissolving halftone.
const BAYER_4 = [
0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,
];
function Dither({ size, speed, reduce }: PartProps) {
const n = 4;
const gap = Math.max(1, size * 0.05);
const cell = (size - gap * (n - 1)) / n;
return (
<span
className="grid"
style={{ gap, gridTemplateColumns: `repeat(${n}, ${cell}px)` }}
>
{BAYER_4.map((order, idx) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: fixed matrix cells, order never changes
key={idx}
className="bg-current"
style={{ width: cell, height: cell }}
animate={reduce ? { opacity: [0.3, 1, 0.3] } : { opacity: [0.1, 1, 0.1] }}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: (order / BAYER_4.length) * speed,
}}
/>
))}
</span>
);
}
"use client";
import { animate, motion, MotionConfig, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Heavy, deliberate thumb — high mass keeps the travel weighty without wobble.
const THUMB_SPRING = { type: "spring", stiffness: 800, damping: 80, mass: 4 } as const;
export interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
label?: string;
ariaLabel?: string;
className?: string;
}
export function Switch({
checked,
onCheckedChange,
disabled,
label,
ariaLabel,
className,
}: SwitchProps) {
const id = useId();
const thumbRef = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const [isPressed, setIsPressed] = useState(false);
const [isPointer, setIsPointer] = useState(false);
// Disabled shake feedback when pressed.
useEffect(() => {
if (!thumbRef.current || reduce) return;
if (disabled && isPressed) {
animate(
thumbRef.current,
{ x: [0, -2, 2, -1, 0] },
{ delay: 0.2, duration: 0.6 },
);
}
}, [disabled, isPressed, reduce]);
const squish = !disabled && isPointer && isPressed && !reduce;
return (
<MotionConfig transition={reduce ? { duration: 0 } : THUMB_SPRING}>
<span className={cn("inline-flex items-center gap-3", className)}>
<motion.button
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
onPointerDown={(e) => {
setIsPressed(true);
setIsPointer(e.type.startsWith("pointer"));
}}
onPointerUp={() => setIsPressed(false)}
onPointerLeave={() => setIsPressed(false)}
initial={false}
data-state={checked ? "checked" : "unchecked"}
className={cn(
"group peer relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center px-1 rounded-full outline-none transition-colors duration-200",
TOUCH_TARGET_CLASS,
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
checked ? "justify-end bg-primary" : "justify-start bg-muted-foreground/60",
)}
>
<motion.div
ref={thumbRef}
layout
animate={{ scale: squish ? 0.9 : 1 }}
className="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-md"
>
{/* Stretch toward the destination while active. */}
<div
className={cn(
"size-5",
squish && (checked ? "ml-1" : "mr-1"),
)}
/>
</motion.div>
</motion.button>
{label ? (
<label htmlFor={id} className="cursor-pointer text-sm text-foreground">
{label}
</label>
) : null}
</span>
</MotionConfig>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
labelstringAccessible name of the field ("Name", "Bio"); also used in the edit hint.
—value?string—defaultValue?string—onSave?((next: string) => unknown)Commit handler. Return a promise to show a pending spinner; the shown value rolls back (with a shake and the rejection message) if it rejects. Controlled callers update `value` here.
—onCancel?(() => void)—onEditingChange?((editing: boolean) => void)—validate?((next: string) => string | false | null)Return an error message to block the save and keep the editor open.
—multiline?booleanEdit in an auto-growing textarea; Enter saves, Shift+Enter adds a line.
falseplaceholder?stringEmptymaxLength?number—selectOnEdit?booleanSelect the whole text when the editor opens (default) or put the caret at the end.
truedisabled?boolean—className?string—classNames?InlineEditClassNames—Updated