bottega

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):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/shake-validation-input

Usage

Usagetsx
<ShakeValidationInput />

Props

Standard props — see source.


Source

shake-validation-input.tsxtsx
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