primitives
Shake Validation Input
A text input field that physically shakes when its value fails browser-native constraint validation. The shake is triggered entirely by the CSS :invalid pseudo-class combined with a co-located @keyframes justshake animation — no JS required. A numeric pattern (\d+) is the default, but any HTML pattern attribute is supported. The field suppresses the shake on empty-but-unfilled required fields via :not(:placeholder-shown) so the animation only fires once the user has typed something invalid. Invalid state is also signalled via a token-derived color shift on the text and border, providing a dual cue (motion + colour). Includes a visible <label> wired via htmlFor/id, focus-visible ring, disabled state, and forwardRef for controlled form integration.
Ported from Galahhad (MIT)
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/shake-validation-inputUsage
<ShakeValidationInput />Props
Standard props — see source.
Source
import * as React from "react";
import styles from "./shake-validation-input.module.css";
export type ShakeValidationInputProps = {
/** Input label — rendered as a visually-associated <label>. */
label: string;
/** Controlled value. */
value?: string;
/** Change handler. */
onChange?: React.ChangeEventHandler<HTMLInputElement>;
/** Placeholder text shown inside the field. */
placeholder?: string;
/**
* HTML pattern used by the browser's native constraint API.
* When the current value doesn't match, the input receives :invalid and shakes.
* Default: "\\d+" (numbers only).
*/
pattern?: string;
/** input[type] attribute — "text" | "password" | "email" | "tel" etc. (default "text"). */
type?: React.HTMLInputTypeAttribute;
/** Disables the field and suppresses validation + shake. */
disabled?: boolean;
/** Whether the field is required. */
required?: boolean;
/** Additional class names merged onto the wrapper element. */
className?: string;
/** id wired to <label htmlFor>. Auto-generated if omitted. */
id?: string;
} & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange" | "type" | "disabled" | "pattern" | "id" | "placeholder" | "required"
>;
let _uid = 0;
function useId(supplied?: string): string {
const [id] = React.useState(() => supplied ?? `shake-input-${++_uid}`);
return supplied ?? id;
}
export const ShakeValidationInput = React.forwardRef<
HTMLInputElement,
ShakeValidationInputProps
>(function ShakeValidationInput(
{
label,
value,
onChange,
placeholder = "Numbers only or shake",
pattern = "\\d+",
type = "text",
disabled = false,
required = false,
className,
id: suppliedId,
...rest
},
ref,
) {
const id = useId(suppliedId);
return (
<div className={[styles.wrapper, className].filter(Boolean).join(" ")}>
{/* Visually-associated label — never suppressed; screen-readers pick it up */}
<label className={styles.label} htmlFor={id}>
{label}
</label>
<input
ref={ref}
id={id}
className={styles.input}
type={type}
value={value}
onChange={onChange}
placeholder={placeholder}
pattern={pattern}
disabled={disabled}
required={required}
{...rest}
/>
</div>
);
});
ShakeValidationInput.displayName = "ShakeValidationInput";
Dependencies
- @bottega/tokens