Documentation Index
Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt
Use this file to discover all available pages before exploring further.
Version: 2.0.0
Last Updated: 2026-04-19
Spec Reference: PF-37 — Mobile Swipe Gestures
Integration Doc: docs/architecture/integrations/PF-37-phase-2-3-INTEGRATION.md
Status: ✅ Phase 1 + Phase 2 + Phase 3 Complete (PF-37); ongoing platform consolidation in this branch
Major changes vs v1.0.0:
- Adds Phase 2 (preferences, haptics, batched analytics) and Phase 3 (pinch-to-zoom, multi-touch, custom gestures) coverage.
- Adds the new
SwipeableCardShell primitive and the deprecation of hand-rolled *Swipeable.tsx wrappers.
- Documents
prefers-reduced-motion behavior across Phase 1 hooks.
- Adds cross-references to
/settings/gestures and /settings/gestures/analytics.
- Aligns CSS-var examples with production (
--mobile-nav-height: 76px, --mobile-header-height: 48px).
Overview
This guide covers mobile gesture patterns implemented in the Encore Health OS platform under PF-37. All gestures use @use-gesture/react for performant touch handling, are mobile-first, accessibility-aware, and honor:
- User preferences stored under
pf_profiles.preferences.gestures (see PF-37 Phase 2).
- Haptic feedback via the Vibration API where supported.
- Batched analytics to
pf_gesture_analytics_events (5-second flush, no PHI).
- Reduced motion via
prefers-reduced-motion: reduce.
- Keyboard alternatives per WCAG 2.1.1, 2.5.1, and 2.5.2.
Table of Contents
- Quick Start
- Hooks Catalog
- Components Catalog
- Decision Tree — When to Use Each Pattern
- User Preferences (Phase 2)
- Haptic Feedback (Phase 2)
- Analytics (Phase 2)
- Pinch & Multi-touch (Phase 3)
- Custom Gesture Registry (Phase 3)
- Accessibility & Reduced Motion
- Performance Guidelines
- Browser Compatibility
- Troubleshooting
- Related Documentation
Quick Start
Import Gestures
import {
// Phase 1 — Hooks
useSwipeToDismiss,
useSwipeActions,
usePullToRefresh,
useEdgeSwipe,
// Phase 1 — Components
SwipeableSheet,
SwipeableListItem,
PullToRefresh,
MobilePullToRefresh,
// Phase 2 — Preferences / haptics / analytics
useGesturePreferences,
useHapticFeedback,
useGestureAnalytics,
useGestureIntegration,
// Phase 3 — Pinch & multi-touch
usePinchToZoom,
useMultiTouch,
useDoubleTap,
ZoomableImage,
ZoomableImageViewer,
// Phase 3 — Custom gestures
useCustomGesture,
CustomGestureProvider,
useCustomGestureRegistry,
// Constants
GESTURE_THRESHOLDS,
ANIMATION_CONFIG,
ACTION_COLORS,
GESTURE_ARIA_LABELS,
// Shared list-item shell (recommended)
SwipeableCardShell,
} from '@/platform/gestures';
Basic Usage
// Swipe-to-dismiss bottom sheet
<SwipeableSheet open={open} onOpenChange={setOpen} side="bottom" ariaLabelledBy="sheet-title">
<h2 id="sheet-title">Sheet title</h2>
</SwipeableSheet>
// Mobile-only swipe-revealed actions on a list card (recommended pattern)
<SwipeableCardShell
rightActions={[{ id: 'delete', label: 'Delete', icon: 'Trash2', colorClass: 'destructive', onClick: handleDelete }]}
>
<NotificationItem ... />
</SwipeableCardShell>
// Pull-to-refresh that auto-disables on desktop / pointer-fine devices
<MobilePullToRefresh onRefresh={async () => await refetch()}>
<ScrollableList />
</MobilePullToRefresh>
Hooks Catalog
Phase 1 — core gesture hooks
| Hook | Purpose | Honors preferences? | Honors reduced-motion? |
|---|
useSwipeToDismiss | Swipe sheets/dialogs/toasts to dismiss | ✅ via useGestureIntegration | ✅ snap-back duration → 0 |
useSwipeActions | iOS-Mail-style reveal of action buttons | ✅ | ✅ reveal duration → 0 |
usePullToRefresh | Pull down at scroll-top to refetch | ✅ | ✅ |
useEdgeSwipe | Swipe from left edge to navigate back | ✅ | ✅ |
Phase 2 — preferences, haptics, analytics
| Hook | Purpose |
|---|
useGesturePreferences | Read/write user preferences (sensitivity, toggles, haptics) |
useHapticFeedback | Trigger Vibration API feedback when available |
useGestureAnalytics | Read aggregated user analytics for the settings dashboard |
useGestureIntegration | Internal helper combining preferences + analytics + haptics — Phase 1 hooks call it automatically |
Phase 3 — advanced gestures
| Hook | Purpose |
|---|
usePinchToZoom | Two-finger pinch to scale an element |
useMultiTouch | Track multi-finger touch state |
useDoubleTap | Detect double-tap with timing tolerance |
useCustomGesture | Recognize a registered custom pattern |
Components Catalog
| Component | Use case |
|---|
SwipeableSheet | Sheet/drawer with swipe-to-dismiss + drag handle |
SwipeableListItem | Low-level swipe-actions row (prefer SwipeableCardShell from @/platform/gestures) |
SwipeableCardShell (new — see below) | Auto-mobile-detecting wrapper around SwipeableListItem; recommended for all list cards |
PullToRefresh | Pull-to-refresh wrapper (always-on; controlled by enabled prop) |
MobilePullToRefresh | Pull-to-refresh wrapper that auto-disables on non-mobile (use this for most lists) |
ZoomableImage | Pinch-zoomable image |
ZoomableImageViewer | Full-screen image viewer with pinch + double-tap zoom |
Why SwipeableCardShell?
We had 5 nearly identical hand-rolled wrappers (OnboardingTaskCardSwipeable, NotificationItemSwipeable, DraftCardSwipeable, SwipeableWidget, TodoCardSwipeable), each repeating the same useIsMobile() + SwipeableListItem boilerplate. SwipeableCardShell gives one canonical primitive:
import { SwipeableCardShell } from '@/platform/gestures';
<SwipeableCardShell
leftActions={[{ id: 'complete', label: 'Complete', icon: 'Check', colorClass: 'success', onClick: onComplete }]}
rightActions={[{ id: 'delete', label: 'Delete', icon: 'Trash2', colorClass: 'destructive', onClick: onDelete }]}
>
<MyCard ... />
</SwipeableCardShell>
Behavior:
- On desktop (or when
enabled === false): renders children unwrapped (zero gesture overhead).
- On mobile: wraps with
SwipeableListItem and forwards all action / threshold / className props.
- Honors
useGestureIntegration automatically (because the underlying hook does).
Decision Tree — When to Use Each Pattern
Is this a list/row that needs row-level actions?
├─ Yes → SwipeableCardShell (mobile-only, auto-disables on desktop)
└─ No
├─ Bottom sheet / drawer that should swipe-to-close?
│ └─ SwipeableSheet (also adds drag handle on bottom side)
├─ Scrollable list / page that should refresh on pull-down?
│ └─ MobilePullToRefresh (auto-mobile)
├─ Detail/edit page where left-edge swipe should go back?
│ └─ Already wired into the mobile <main>; no per-page work needed
├─ Image / media that should pinch-zoom?
│ └─ ZoomableImage or ZoomableImageViewer (full-screen modal)
└─ Anything else (custom pattern, multi-finger, double-tap)
└─ Phase 3 hooks (use sparingly — almost everything is covered above)
When NOT to use gestures
- ❌ Forms with unsaved changes (use a confirmation dialog).
- ❌ Destructive actions without undo (require confirmation or 5-second toast undo).
- ❌ Complex multi-step interactions (use a wizard).
- ❌ Desktop-only interfaces (most gestures auto-disable, but don’t go out of your way).
- ❌ When keyboard-only users are the primary audience for that surface.
User Preferences (Phase 2)
Each user can tune gestures at /settings/gestures (page: GesturePreferencesPage.tsx).
Schema stored in pf_profiles.preferences.gestures:
interface GesturePreferences {
enabled: boolean; // master toggle
sensitivity: 'low' | 'medium' | 'high';
sensitivity_multiplier: number; // 0.5 – 2.0
haptic_feedback: {
enabled: boolean;
intensity: 'none' | 'light' | 'medium' | 'strong';
patterns: { success: boolean; error: boolean; warning: boolean };
};
gesture_toggles: {
swipe_to_dismiss: boolean;
swipe_actions: boolean;
pull_to_refresh: boolean;
edge_swipe: boolean;
};
}
How it cascades:
useSwipeToDismiss → useGestureIntegration ─┬─ preferences.enabled
useSwipeActions ─┘ ├─ gesture_toggles.<type>
usePullToRefresh ─┐ ├─ sensitivity_multiplier (clamped 0.1–5.0)
useEdgeSwipe ─┘ └─ haptic_feedback.intensity / patterns
If the master toggle is off, all gestures no-op silently. The hooks still mount; they just don’t bind.
Haptic Feedback (Phase 2)
Use the new hook (preferred):
import { useHapticFeedback } from '@/platform/gestures';
const { vibrate } = useHapticFeedback();
vibrate('success'); // or 'tap' | 'selection' | 'warning' | 'error' | 'longPress'
Browser support matrix:
| Browser / OS | Vibration API | Notes |
|---|
| Chrome (Android) | ✅ | Honors navigator.vibrate(pattern) |
| Firefox (Android) | ✅ | Same |
| Edge (Android) | ✅ | Same |
| Safari (iOS) — all versions | ❌ | Vibration API is a no-op; hook silently does nothing |
| Chrome / Edge / Firefox / Safari (desktop) | ❌ | No-op |
The legacy Phase-1 functions (hapticTap, hapticSuccess, …) are still exported from @/platform/gestures for backward compatibility but prefer useHapticFeedback in new code.
Analytics (Phase 2)
Gesture events are batched to pf_gesture_analytics_events:
- Append-only, user-scoped RLS (a user can only insert their own rows).
- No PHI: only
gesture_type, subtype (e.g. 'left', 'right'), success: boolean, device_type, timestamp. Never patient names / MRNs / IDs.
- Batched client-side: 5-second flush +
visibilitychange + beforeunload flush.
Aggregated counts are exposed at /settings/gestures/analytics (GestureAnalyticsPage.tsx) so users can see how often they use each gesture and what their success rate is.
Privacy note: Do not pass any user-typed input or entity identifiers to the optional subtype parameter of trackGestureEvent. The TypeScript signature constrains it to a SwipeDirection-shaped value; do not widen it.
Pinch & Multi-touch (Phase 3)
import { ZoomableImage } from '@/platform/gestures';
<ZoomableImage src={signedUrl} alt="Patient ID card" maxScale={4} />
Use cases:
- Image lightboxes (PF-98 headshot viewer; document image viewer in
@/platform/documents).
- Charts you want to zoom into.
- Signature previews.
For full-screen viewing with double-tap zoom + pinch + pan, use ZoomableImageViewer.
Avoid useMultiTouch and useCustomGesture unless the existing higher-level components do not cover the case. They are unstable surface area — call PF before adopting.
Custom Gesture Registry (Phase 3)
Wrap a subtree:
<CustomGestureProvider>
<YourFeature />
</CustomGestureProvider>
Then register and consume:
const registry = useCustomGestureRegistry();
useEffect(() => {
registry.register({
id: 'circle',
pattern: { type: 'shape', shape: 'circle' },
onMatch: () => console.log('Circle drawn'),
});
return () => registry.unregister('circle');
}, [registry]);
This is intentionally low-traffic; for almost all product features, the named hooks above are simpler.
Accessibility & Reduced Motion
WCAG compliance
| Guideline | Implementation |
|---|
| 2.1.1 Keyboard | Every gesture has a keyboard alternative (Esc, Delete, Enter, Tab). |
| 2.5.1 Pointer Gestures | Single-pointer fallback (tap on action button, click button, button-trigger refresh). |
| 2.5.2 Pointer Cancellation | Drag-back-to-cancel works; below-threshold swipe snaps back. |
| 2.3.3 Animation from Interactions | prefers-reduced-motion: reduce skips snap/dismiss animations and reveals (jump-cut). |
Keyboard alternatives
| Gesture | Keyboard alternative |
|---|
| Swipe to dismiss | Esc (or browser back) |
| Swipe-to-delete | Delete key when focused |
| Swipe to reveal actions | Tab to focus, Enter/Space to activate |
| Pull to refresh | Refresh button, Ctrl+R, or browser refresh |
| Edge swipe back | Backspace, browser back, on-screen back button |
| Pinch to zoom | + / - keyboard shortcuts in viewers, on-screen zoom controls |
Reduced-motion behavior
Set prefers-reduced-motion: reduce (system / browser) and the four Phase-1 hooks switch to:
- Snap-back:
transition: none (jump cut).
- Dismiss animation:
0 ms (immediate cleanup).
- Reveal animation:
0 ms.
Haptic feedback is independent of reduced-motion and remains controlled by the user-preference toggle.
| Metric | Target | Status |
|---|
| Gesture recognition latency | < 16 ms | ✅ |
| Animation frame rate | 60 fps | ✅ |
| Bundle size (gestures module) | < 15 KB gzip | ✅ (@use-gesture/react is ~5.3 KB gzip) |
| Memory overhead | < 5 MB | ✅ |
| Analytics flush overhead | < 1 ms / batch | ✅ (fire-and-forget) |
Best practices
- Use CSS transforms only. All hooks emit
transform: translateX/Y(...) and never modify width/height/left/top.
- Avoid re-renders during drag.
useDrag callbacks update component state via setState; keep the consumer subtree small.
- Don’t add
will-change unless DevTools shows compositing issues — Safari has known memory regressions.
- Batch onRefresh side effects. Resolve the promise before triggering query invalidation; the hook will hold the spinner until your promise settles.
Browser Compatibility
| Browser | Version | Touch | Mouse fallback |
|---|
| iOS Safari | 14+ | ✅ Full | n/a |
| Android Chrome | 90+ | ✅ Full | n/a |
| Desktop Chrome | 90+ | n/a | ✅ |
| Desktop Firefox | 90+ | n/a | ✅ |
| Desktop Safari | 14+ | n/a | ✅ |
| Microsoft Edge | 90+ | ✅ touch on hybrids | ✅ |
Known limitations
- iOS Safari edge-swipe collision. The native iOS edge-back gesture lives in roughly the leftmost 30 px. Our
useEdgeSwipe zone is 20 px. On iOS the native gesture wins — that’s fine; users still get the expected behavior, it’s just routed through the browser instead of our hook.
- iOS Vibration API. Permanently unsupported.
useHapticFeedback is a no-op there.
- Android system back gesture (Android 10+). Coexists with our hooks; no conflicts seen in QA.
Troubleshooting
Gesture isn’t firing
- Check the user has not disabled the gesture in
/settings/gestures.
- Check the
enabled prop / option (default is true).
- Check the threshold isn’t too high; lower it temporarily and retest.
- Make sure
touch-action on a parent isn’t pan-x/pan-y blocking the relevant axis.
- Try wrapping the parent in
<MobilePullToRefresh enabled> to confirm touch handling is reaching the area.
Janky animation
- Confirm only
transform / opacity change during the gesture.
- Open Chrome DevTools → Rendering → “Paint flashing” to spot non-composited repaints.
- Verify the consumer subtree isn’t re-rendering on every progress tick.
- Pull-to-refresh only fires at
scrollTop === 0.
- Edge-swipe only fires inside the 20 px left zone.
- Sheet swipe-to-dismiss respects axis (
pan-x or pan-y).
Keyboard shortcut doesn’t work
- Ensure the swipeable container is focusable (
tabIndex={0} or focusable child).
- Verify no parent calls
event.stopPropagation() on key events.
- For destructive actions, the keyboard handler is on the action button, not the row — focus the button via
Tab.
Document Status: Active
Maintained By: Platform Foundation Team
Last Reviewed: 2026-04-19