Enforces designed interaction feedback instead of the framework default — every committing interaction acknowledged in the same frame, motion that decorates state but never IS the state (the end state is identical with animations off), a named catalog of moments each declaring its trigger, duration role, haptic slot and reduced-motion fallback at design time, haptics as a HapticTheme event-to-intensity map fired exactly once on the commit frame (never per animation frame, never per coalesced input, never heavyImpact for an error) behind one central toggle, every animation interruptible so a tap resolves it to its end state, a bounded celebration that plays once and never loops or blocks input, an explicit stop condition on every repeating animation (off-route, backgrounded, reduced motion), and feedback on more than one channel so haptics and sound survive reduced motion. Use when adding or tuning an animation, a success or error state, a haptic, a celebration, or a screen transition.
What fires, when, exactly once, and how it degrades. Feedback is the layer users feel rather than
read — an app whose taps land, whose errors are felt, and whose success is acknowledged reads as
finished; the same app with default Material feedback reads as a prototype.
Scope boundary. Motion tokens — duration roles, curve slots, the no-raw-values gate, and
resolveMotion collapsing to Duration.zero under reduced motion — are owned by
design-system-structure. Painter/ticker mechanics are owned by custom-canvas-and-gestures.
Which surface a message uses — loading, empty, error, snackbar, banner, dialog, Undo, and
screen-reader announcements — is owned by ui-states-and-feedback. This skill owns the sensory
layer those decisions ride on: which moments exist, what each one commits to, how haptics are
mapped and fired, and what survives when the animation does not.
Non-negotiable rules
Every committing interaction is acknowledged in the same frame it happens. Finger-down shows a
press state immediately; the commit's own feedback follows on commit. WHY: a control that waits
for an async result before showing anything is indistinguishable from a control that did not
register the tap, and the user taps again.
Motion decorates state; it is never the state. The end state must be identical, correct, and
reachable with all animations disabled. A selection that only exists while its animation runs, or
a value that is only legible mid-transition, is a bug that reduced-motion users hit every time.
Name your moments and design each one deliberately. Every app has a small catalog — press
acknowledgment, rejected input, the commit payoff, success, progress or streak increment, and the
signature screen transition. Each moment declares five things: its trigger, what changes,
which duration role and curve it spends, its haptic slot, and its reduced-motion
fallback. A moment left unspecified is not "minimal" — it is the framework default, chosen by
nobody. See references/moment-catalog.md.
Every animation declares its reduced-motion behaviour at design time. "It is small, nobody will
mind" is not a fallback. The mechanism (resolveMotion → Duration.zero) belongs to
design-system-structure; the decision — cross-fade to the end state, appear already-complete,
omit the flourish entirely — is made per moment, here, and written down. Feature code never
re-reads the platform flag per animation.
Haptics are a map, not scattered calls. A HapticTheme extension maps each event to at most
one HapticFeedback verb: selection ≤ selectionClick, commit ≤ lightImpact/mediumImpact,
rejection ≤ lightImpact, success ≤ mediumImpact, and heavyImpact reserved for a genuine
milestone. A null slot is a declared silence, not an omission. WHY: haptics added ad hoc at
call sites always drift upward in intensity until the app buzzes.
Fire one haptic per committed event, on the commit frame. Never inside an animation callback,
never per frame, never once per input in a coalesced gesture — a drag emits at most an engage and
a commit. WHY: a haptic per frame is a vibration, and a haptic per coalesced input turns a fast
gesture into a rattle.
heavyImpact is never the error sound. Escalating intensity for failure punishes the user for
a mistake the UI could have prevented. Rejection is acknowledged, not amplified.
One in-app toggle gates every haptic, celebrations included. Platforms have their own coarse
system-haptics setting; an app that fires haptics needs its own switch too, and it must be a single
central gate rather than a flag each call site remembers to check.
Every animation is interruptible. A tap during a transition or celebration resolves it to its
end state immediately. Never block input for the duration of a flourish, and never make the user
wait out a success. WHY: the second time a user sees a celebration they want to be past it.
Celebrations have a budget. Bounded element count, bounded time to the next interactive
state, originating from the point of action rather than the screen edges, played once, never
looped. Failure states get acknowledgment with no shaming motion. WHY: an unbounded celebration
is a load screen the user did not ask for.
Every repeating or ambient animation declares a stop condition. It stops when its route is not
current, when the app is backgrounded, and under reduced motion. A repeat() with no stop
condition is a battery bug that no test will catch and no reviewer will see.
Feedback is redundant across channels. Haptics and sound survive reduced motion precisely
because they are the remaining channels — so a state change may never be carried by motion alone.
(Color-alone is owned by accessibility-as-code; this is the motion-alone half of the same rule.)
Sound, if any, is the third channel and never the first. Respect the platform's silent
behaviour, keep it short, never require it to understand what happened, and gate it behind the
same kind of central toggle as haptics.
Transitions follow reading direction; a fixed content surface does not re-mirror. A horizontal
push flips under RTL because navigation is directional; a canvas, board, chart, or media surface
whose own layout is direction-neutral stays as it is. Direction geometry is owned by
i18n-rtl-l10n.
The moment declaration
Write each moment down before implementing it. The table is the specification — an implementation
that cannot fill a column has not finished designing that moment.
Moment
Trigger
What changes
Duration role
Haptic
Reduced-motion fallback
Press
pointer down
press transform
shortest
selectionClick
instant state, no transform
Reject
invalid input
bounded shake + message
short, interruptible
lightImpact
message only, no shake
Commit
state committed
the transformation itself
medium
lightImpact
end state appears instantly
Success
task complete
acknowledgment + result
long, skippable
mediumImpact
result appears already complete
Increment
counter advances
one emphasis pulse
short
selectionClick or silence
new value, no pulse
Transition
route change
the signature transition
medium
none
cross-fade or none
Durations here are roles, not numbers — the numbers are tokens owned by design-system-structure.
Firing a haptic exactly once
// The event map lives in the theme; call sites name an event, never an intensity.
@immutable
class HapticTheme extends ThemeExtension<HapticTheme> {
const HapticTheme({this.select, this.commit, this.reject, this.success});
/// A null slot is a DECLARED silence — the design said no haptic here.
final Future<void> Function()? select;
final Future<void> Function()? commit;
final Future<void> Function()? reject;
final Future<void> Function()? success;
// copyWith / lerp omitted — see design-system-structure for the ThemeExtension contract.
}
// One central gate; no call site remembers to check the setting.
void fire(WidgetRef ref, Future<void> Function()? slot) {
if (!ref.read(hapticsEnabledProvider)) return;
slot?.call();
}
// At the commit — once, on the frame the state actually changed (rules 6, 8).
void onCommit(BuildContext context, WidgetRef ref, EditIntent intent) {
ref.read(itemsProvider.notifier).apply(intent); // state first
fire(ref, Theme.of(context).extension<HapticTheme>()!.commit);
}
Never call HapticFeedback.* from an AnimationController listener, an onEnd callback, or a
per-item loop — those are the three places a single event becomes a burst.
Anti-patterns
A control with no press state — every "did that register?" double-tap starts here.
State that only exists while an animation runs — turn animations off and the app must still be
correct and legible.
An animation with no designed reduced-motion behaviour, or feature code re-reading the platform
flag per animation.
HapticFeedback.mediumImpact() sprinkled at call sites — map events to slots once; ad-hoc
haptics only ever escalate.
A haptic in an animation listener, an onEnd, or per item in a coalesced gesture — one per
committed event, on the commit frame.
heavyImpact for an error — acknowledge failure, do not punish it.
An un-skippable celebration, or blocked input during a flourish — a tap resolves it instantly.
A looping celebration, edge-spawned particles, or an unbounded element count — budget it.
A "sad" failure animation — the user already knows.
repeat() with no stop condition — it must stop off-route, backgrounded, and under reduced motion.
Sound as the only signal, or sound that ignores the platform's silent behaviour.
A mirrored content surface under RTL — navigation direction flips, a direction-neutral canvas does not.
Definition of done
Every committing interaction acknowledges within the same frame.
Every moment touched has a filled-in declaration row: trigger, change, duration role, haptic
slot, reduced-motion fallback.
The end state is identical and reachable with animations disabled; nothing is motion-only.
Haptics come from the HapticTheme map, are at or below the intensity ceiling for their event,
fire once per committed event on the commit frame, and are gated by one central toggle.
null haptic slots are deliberate silences, recorded as such.
Every animation is interruptible; a tap resolves it to its end state; input is never blocked.
Celebrations are bounded, originate from the point of action, play once, and never loop.
Every repeating animation has an explicit stop condition for off-route, backgrounded, and
reduced motion.
Sound (if any) is redundant, short, silent-mode-aware, and centrally gated.
Transitions respect reading direction; direction-neutral content surfaces are not re-mirrored.
Related skills
See design-system-structure for the motion tokens, curve slots, and the resolveMotion helper
this skill spends — including the three animations Material mounts by default.
See custom-canvas-and-gestures for driving canvas animation from an AnimationController as
repaint: and for RepaintBoundary placement.
See ui-states-and-feedback for the state a moment is feedback about — loading/empty/error
rendering, the snackbar/banner/dialog ladder, Undo, and SemanticsService.announce.
See accessibility-as-code for the color-alone rule this skill's motion-alone rule mirrors, and for
reading platform accessibility flags.
See flutter-performance for disposal, boundary budgeting, and profile-mode measurement of anything
that feels heavy.
See i18n-rtl-l10n for directional geometry under RTL.
See design-review-workflow for the end-of-build pass where every declared moment is verified on
video with reduced motion on and off.
1---2name: motion-and-haptics3description: Enforces designed interaction feedback instead of the framework default — every committing interaction acknowledged in the same frame, motion that decorates state but never IS the state (the end state is identical with animations off), a named catalog of moments each declaring its trigger, duration role, haptic slot and reduced-motion fallback at design time, haptics as a HapticTheme event-to-intensity map fired exactly once on the commit frame (never per animation frame, never per coalesced input, never heavyImpact for an error) behind one central toggle, every animation interruptible so a tap resolves it to its end state, a bounded celebration that plays once and never loops or blocks input, an explicit stop condition on every repeating animation (off-route, backgrounded, reduced motion), and feedback on more than one channel so haptics and sound survive reduced motion. Use when adding or tuning an animation, a success or error state, a haptic, a celebration, or a screen transition.4---56# Motion and haptics78What fires, when, exactly once, and how it degrades. Feedback is the layer users feel rather than9read — an app whose taps land, whose errors are felt, and whose success is acknowledged reads as10finished; the same app with default Material feedback reads as a prototype.1112**Scope boundary.** Motion *tokens* — duration roles, curve slots, the no-raw-values gate, and13`resolveMotion` collapsing to `Duration.zero` under reduced motion — are owned by14`design-system-structure`. Painter/ticker mechanics are owned by `custom-canvas-and-gestures`.15*Which* surface a message uses — loading, empty, error, snackbar, banner, dialog, Undo, and16screen-reader announcements — is owned by `ui-states-and-feedback`. This skill owns the sensory17layer those decisions ride on: which moments exist, what each one commits to, how haptics are18mapped and fired, and what survives when the animation does not.1920## Non-negotiable rules21221. **Every committing interaction is acknowledged in the same frame it happens.** Finger-down shows a23 press state immediately; the commit's own feedback follows on commit. WHY: a control that waits24 for an async result before showing anything is indistinguishable from a control that did not25 register the tap, and the user taps again.26272. **Motion decorates state; it is never the state.** The end state must be identical, correct, and28 reachable with all animations disabled. A selection that only exists while its animation runs, or29 a value that is only legible mid-transition, is a bug that reduced-motion users hit every time.30313. **Name your moments and design each one deliberately.** Every app has a small catalog — press32 acknowledgment, rejected input, the commit payoff, success, progress or streak increment, and the33 signature screen transition. Each moment declares five things: its **trigger**, what **changes**,34 which **duration role** and curve it spends, its **haptic slot**, and its **reduced-motion35 fallback**. A moment left unspecified is not "minimal" — it is the framework default, chosen by36 nobody. See `references/moment-catalog.md`.37384. **Every animation declares its reduced-motion behaviour at design time.** "It is small, nobody will39 mind" is not a fallback. The mechanism (`resolveMotion` → `Duration.zero`) belongs to40 `design-system-structure`; the *decision* — cross-fade to the end state, appear already-complete,41 omit the flourish entirely — is made per moment, here, and written down. Feature code never42 re-reads the platform flag per animation.43445. **Haptics are a map, not scattered calls.** A `HapticTheme` extension maps each event to at most45 one `HapticFeedback` verb: selection ≤ `selectionClick`, commit ≤ `lightImpact`/`mediumImpact`,46 rejection ≤ `lightImpact`, success ≤ `mediumImpact`, and `heavyImpact` reserved for a genuine47 milestone. A **`null` slot is a declared silence**, not an omission. WHY: haptics added ad hoc at48 call sites always drift upward in intensity until the app buzzes.49506. **Fire one haptic per committed event, on the commit frame.** Never inside an animation callback,51 never per frame, never once per input in a coalesced gesture — a drag emits at most an engage and52 a commit. WHY: a haptic per frame is a vibration, and a haptic per coalesced input turns a fast53 gesture into a rattle.54557. **`heavyImpact` is never the error sound.** Escalating intensity for failure punishes the user for56 a mistake the UI could have prevented. Rejection is acknowledged, not amplified.57588. **One in-app toggle gates every haptic, celebrations included.** Platforms have their own coarse59 system-haptics setting; an app that fires haptics needs its own switch too, and it must be a single60 central gate rather than a flag each call site remembers to check.61629. **Every animation is interruptible.** A tap during a transition or celebration resolves it to its63 end state immediately. Never block input for the duration of a flourish, and never make the user64 wait out a success. WHY: the second time a user sees a celebration they want to be past it.656610. **Celebrations have a budget.** Bounded element count, bounded time to the next interactive67 state, originating from the point of action rather than the screen edges, **played once, never68 looped**. Failure states get acknowledgment with no shaming motion. WHY: an unbounded celebration69 is a load screen the user did not ask for.707111. **Every repeating or ambient animation declares a stop condition.** It stops when its route is not72 current, when the app is backgrounded, and under reduced motion. A `repeat()` with no stop73 condition is a battery bug that no test will catch and no reviewer will see.747512. **Feedback is redundant across channels.** Haptics and sound survive reduced motion precisely76 because they are the remaining channels — so a state change may never be carried by motion alone.77 (Color-alone is owned by `accessibility-as-code`; this is the motion-alone half of the same rule.)787913. **Sound, if any, is the third channel and never the first.** Respect the platform's silent80 behaviour, keep it short, never require it to understand what happened, and gate it behind the81 same kind of central toggle as haptics.828314. **Transitions follow reading direction; a fixed content surface does not re-mirror.** A horizontal84 push flips under RTL because navigation is directional; a canvas, board, chart, or media surface85 whose own layout is direction-neutral stays as it is. Direction geometry is owned by86 `i18n-rtl-l10n`.8788## The moment declaration8990Write each moment down before implementing it. The table *is* the specification — an implementation91that cannot fill a column has not finished designing that moment.9293| Moment | Trigger | What changes | Duration role | Haptic | Reduced-motion fallback |94|---|---|---|---|---|---|95| Press | pointer down | press transform | shortest | `selectionClick` | instant state, no transform |96| Reject | invalid input | bounded shake + message | short, interruptible | `lightImpact` | message only, no shake |97| Commit | state committed | the transformation itself | medium | `lightImpact` | end state appears instantly |98| Success | task complete | acknowledgment + result | long, skippable | `mediumImpact` | result appears already complete |99| Increment | counter advances | one emphasis pulse | short | `selectionClick` or silence | new value, no pulse |100| Transition | route change | the signature transition | medium | none | cross-fade or none |101102Durations here are *roles*, not numbers — the numbers are tokens owned by `design-system-structure`.103104## Firing a haptic exactly once105106```dart107// The event map lives in the theme; call sites name an event, never an intensity.108@immutable109class HapticTheme extends ThemeExtension<HapticTheme> {110 const HapticTheme({this.select, this.commit, this.reject, this.success});111112 /// A null slot is a DECLARED silence — the design said no haptic here.113 final Future<void> Function()? select;114 final Future<void> Function()? commit;115 final Future<void> Function()? reject;116 final Future<void> Function()? success;117 // copyWith / lerp omitted — see design-system-structure for the ThemeExtension contract.118}119120// One central gate; no call site remembers to check the setting.121void fire(WidgetRef ref, Future<void> Function()? slot) {122 if (!ref.read(hapticsEnabledProvider)) return;123 slot?.call();124}125126// At the commit — once, on the frame the state actually changed (rules 6, 8).127void onCommit(BuildContext context, WidgetRef ref, EditIntent intent) {128 ref.read(itemsProvider.notifier).apply(intent); // state first129 fire(ref, Theme.of(context).extension<HapticTheme>()!.commit);130}131```132133Never call `HapticFeedback.*` from an `AnimationController` listener, an `onEnd` callback, or a134per-item loop — those are the three places a single event becomes a burst.135136## Anti-patterns137138- **A control with no press state** — every "did that register?" double-tap starts here.139- **State that only exists while an animation runs** — turn animations off and the app must still be140 correct and legible.141- **An animation with no designed reduced-motion behaviour**, or feature code re-reading the platform142 flag per animation.143- **`HapticFeedback.mediumImpact()` sprinkled at call sites** — map events to slots once; ad-hoc144 haptics only ever escalate.145- **A haptic in an animation listener, an `onEnd`, or per item in a coalesced gesture** — one per146 committed event, on the commit frame.147- **`heavyImpact` for an error** — acknowledge failure, do not punish it.148- **An un-skippable celebration, or blocked input during a flourish** — a tap resolves it instantly.149- **A looping celebration, edge-spawned particles, or an unbounded element count** — budget it.150- **A "sad" failure animation** — the user already knows.151- **`repeat()` with no stop condition** — it must stop off-route, backgrounded, and under reduced motion.152- **Sound as the only signal**, or sound that ignores the platform's silent behaviour.153- **A mirrored content surface under RTL** — navigation direction flips, a direction-neutral canvas does not.154155## Definition of done156157- [ ] Every committing interaction acknowledges within the same frame.158- [ ] Every moment touched has a filled-in declaration row: trigger, change, duration role, haptic159 slot, reduced-motion fallback.160- [ ] The end state is identical and reachable with animations disabled; nothing is motion-only.161- [ ] Haptics come from the `HapticTheme` map, are at or below the intensity ceiling for their event,162 fire once per committed event on the commit frame, and are gated by one central toggle.163- [ ] `null` haptic slots are deliberate silences, recorded as such.164- [ ] Every animation is interruptible; a tap resolves it to its end state; input is never blocked.165- [ ] Celebrations are bounded, originate from the point of action, play once, and never loop.166- [ ] Every repeating animation has an explicit stop condition for off-route, backgrounded, and167 reduced motion.168- [ ] Sound (if any) is redundant, short, silent-mode-aware, and centrally gated.169- [ ] Transitions respect reading direction; direction-neutral content surfaces are not re-mirrored.170171## Related skills172173- See `design-system-structure` for the motion tokens, curve slots, and the `resolveMotion` helper174 this skill spends — including the three animations Material mounts by default.175- See `custom-canvas-and-gestures` for driving canvas animation from an `AnimationController` as176 `repaint:` and for `RepaintBoundary` placement.177- See `ui-states-and-feedback` for the state a moment is feedback *about* — loading/empty/error178 rendering, the snackbar/banner/dialog ladder, Undo, and `SemanticsService.announce`.179- See `accessibility-as-code` for the color-alone rule this skill's motion-alone rule mirrors, and for180 reading platform accessibility flags.181- See `flutter-performance` for disposal, boundary budgeting, and profile-mode measurement of anything182 that feels heavy.183- See `i18n-rtl-l10n` for directional geometry under RTL.184- See `design-review-workflow` for the end-of-build pass where every declared moment is verified on185 video with reduced motion on and off.186187## References188189- Flutter API — `HapticFeedback`: https://api.flutter.dev/flutter/services/HapticFeedback-class.html190- Flutter API — `SystemSound`: https://api.flutter.dev/flutter/services/SystemSound-class.html191- Flutter API — `MediaQueryData.disableAnimations`: https://api.flutter.dev/flutter/widgets/MediaQueryData/disableAnimations.html192- Flutter — animations overview: https://docs.flutter.dev/ui/animations193- Material 3 — motion: https://m3.material.io/styles/motion/overview194- Apple HIG — playing haptics: https://developer.apple.com/design/human-interface-guidelines/playing-haptics195- WCAG 2.2 — Animation from Interactions (2.3.3): https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html
Run npx skillmds@latest add zakariaf/motion-and-haptics in your terminal (requires Node.js), paste this page's agent-chat prompt into Claude, Cursor, or any MCP-connected agent, or download the SKILL.md file and copy it into your agent's skills directory.
Enforces designed interaction feedback instead of the framework default — every committing interaction acknowledged in the same frame, motion that decorates state but never IS the state (the end state is identical with animations off), a named catalog of moments each declaring its trigger, duration role, haptic slot and reduced-motion fallback at design time, haptics as a HapticTheme event-to-intensity map fired exactly once on the commit frame (never per animation frame, never per coalesced input, never heavyImpact for an error) behind one central toggle, every animation interruptible so a tap resolves it to its end state, a bounded celebration that plays once and never loops or blocks input, an explicit stop condition on every repeating animation (off-route, backgrounded, reduced motion), and feedback on more than one channel so haptics and sound survive reduced motion. Use when adding or tuning an animation, a success or error state, a haptic, a celebration, or a screen transition. It is listed under Coding & Dev Tools on SkillMD.
This skill has not completed SkillMD's automated safety review yet. SkillMD never runs a skill's scripts for you; review the SKILL.md before installing.
This skill is tagged as working with Claude Code, Claude.ai, OpenAI Codex. SKILL.md is an open format, so most agents that read a skills directory can load it too.
Yes. Installing skills from SkillMD is free, and the skill stays under its author's original license.
zakariaf (@zakariaf) published this skill. Their other Agent Skills are listed on their SkillMD profile.