Shake Field

A field that answers back. The control shakes to acknowledge the refusal and a message opens under it to give the reason.

Loading preview

Installation

npx shadcn@latest add @loomui/shake-field

Usage

import { ShakeField } from "@/components/ui/shake-field"
<ShakeField error={error} attempt={attempt}>
  <label htmlFor="email">Email</label>
  <input id="email" aria-invalid={error ? true : undefined} />
</ShakeField>

How it works

Two halves of one answer

The shake is the acknowledgement and the message is the reason. Either alone is worse. A shake with no text says something is wrong without saying what, and text with no movement is easy to miss under a control you were already looking at.

The same mistake twice

A form submitted three times with the same wrong password should answer three times. React will not restart an animation whose class is already on the element, and an error that has not changed is not a new render to key on.

Pass a counter that goes up on every submit:

const [attempt, setAttempt] = React.useState(0)
 
function submit() {
  setAttempt((n) => n + 1)
  setError(check() ? null : "That password does not match.")
}

Without attempt, a repeated identical message is treated as the error already on screen and nothing moves.

The message opens on a grid row

0fr to 1fr on a grid row, not a height. The panel opens to whatever height the message turns out to be, with nothing measured first and no fixed height to keep in sync when the copy changes. Two lines of error text cost the same code as one.

Typing does not restart it

The effect watches the message and the attempt, not every render. Typing into the field re-renders the parent constantly, and a shake that restarted on each keystroke would be unusable.

Props

PropTypeDefaultDescription
errorReactNodenoneThe message. Anything falsy clears the last one.
attemptnumbernoneBump this to shake again for the same message.
durationnumber420Milliseconds for one shake.
distancestring0.35remHow far the shake travels.
classNamestringnoneMerged onto the wrapper.

Any other <div> prop is forwarded.

Accessibility

The message carries role="alert", so it is announced when it appears and not before. Set aria-invalid on the control yourself: the field wraps your input rather than rendering it, and only you know which element is the one being described.

Reduced motion

Nothing shakes and nothing slides. The message still appears, because it is the half that carries the meaning. An error someone cannot read is worse than an error that arrives without ceremony.