Skip to main content

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

  1. Quick Start
  2. Hooks Catalog
  3. Components Catalog
  4. Decision Tree — When to Use Each Pattern
  5. User Preferences (Phase 2)
  6. Haptic Feedback (Phase 2)
  7. Analytics (Phase 2)
  8. Pinch & Multi-touch (Phase 3)
  9. Custom Gesture Registry (Phase 3)
  10. Accessibility & Reduced Motion
  11. Performance Guidelines
  12. Browser Compatibility
  13. Troubleshooting
  14. 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

HookPurposeHonors preferences?Honors reduced-motion?
useSwipeToDismissSwipe sheets/dialogs/toasts to dismiss✅ via useGestureIntegration✅ snap-back duration → 0
useSwipeActionsiOS-Mail-style reveal of action buttons✅ reveal duration → 0
usePullToRefreshPull down at scroll-top to refetch
useEdgeSwipeSwipe from left edge to navigate back

Phase 2 — preferences, haptics, analytics

HookPurpose
useGesturePreferencesRead/write user preferences (sensitivity, toggles, haptics)
useHapticFeedbackTrigger Vibration API feedback when available
useGestureAnalyticsRead aggregated user analytics for the settings dashboard
useGestureIntegrationInternal helper combining preferences + analytics + haptics — Phase 1 hooks call it automatically

Phase 3 — advanced gestures

HookPurpose
usePinchToZoomTwo-finger pinch to scale an element
useMultiTouchTrack multi-finger touch state
useDoubleTapDetect double-tap with timing tolerance
useCustomGestureRecognize a registered custom pattern

Components Catalog

ComponentUse case
SwipeableSheetSheet/drawer with swipe-to-dismiss + drag handle
SwipeableListItemLow-level swipe-actions row (prefer SwipeableCardShell from @/platform/gestures)
SwipeableCardShell (new — see below)Auto-mobile-detecting wrapper around SwipeableListItem; recommended for all list cards
PullToRefreshPull-to-refresh wrapper (always-on; controlled by enabled prop)
MobilePullToRefreshPull-to-refresh wrapper that auto-disables on non-mobile (use this for most lists)
ZoomableImagePinch-zoomable image
ZoomableImageViewerFull-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 / OSVibration APINotes
Chrome (Android)Honors navigator.vibrate(pattern)
Firefox (Android)Same
Edge (Android)Same
Safari (iOS) — all versionsVibration 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

GuidelineImplementation
2.1.1 KeyboardEvery gesture has a keyboard alternative (Esc, Delete, Enter, Tab).
2.5.1 Pointer GesturesSingle-pointer fallback (tap on action button, click button, button-trigger refresh).
2.5.2 Pointer CancellationDrag-back-to-cancel works; below-threshold swipe snaps back.
2.3.3 Animation from Interactionsprefers-reduced-motion: reduce skips snap/dismiss animations and reveals (jump-cut).

Keyboard alternatives

GestureKeyboard alternative
Swipe to dismissEsc (or browser back)
Swipe-to-deleteDelete key when focused
Swipe to reveal actionsTab to focus, Enter/Space to activate
Pull to refreshRefresh button, Ctrl+R, or browser refresh
Edge swipe backBackspace, 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.

Performance Guidelines

MetricTargetStatus
Gesture recognition latency< 16 ms
Animation frame rate60 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

  1. Use CSS transforms only. All hooks emit transform: translateX/Y(...) and never modify width/height/left/top.
  2. Avoid re-renders during drag. useDrag callbacks update component state via setState; keep the consumer subtree small.
  3. Don’t add will-change unless DevTools shows compositing issues — Safari has known memory regressions.
  4. Batch onRefresh side effects. Resolve the promise before triggering query invalidation; the hook will hold the spinner until your promise settles.

Browser Compatibility

BrowserVersionTouchMouse fallback
iOS Safari14+✅ Fulln/a
Android Chrome90+✅ Fulln/a
Desktop Chrome90+n/a
Desktop Firefox90+n/a
Desktop Safari14+n/a
Microsoft Edge90+✅ touch on hybrids

Known limitations

  1. 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.
  2. iOS Vibration API. Permanently unsupported. useHapticFeedback is a no-op there.
  3. Android system back gesture (Android 10+). Coexists with our hooks; no conflicts seen in QA.

Troubleshooting

Gesture isn’t firing

  1. Check the user has not disabled the gesture in /settings/gestures.
  2. Check the enabled prop / option (default is true).
  3. Check the threshold isn’t too high; lower it temporarily and retest.
  4. Make sure touch-action on a parent isn’t pan-x/pan-y blocking the relevant axis.
  5. Try wrapping the parent in <MobilePullToRefresh enabled> to confirm touch handling is reaching the area.

Janky animation

  1. Confirm only transform / opacity change during the gesture.
  2. Open Chrome DevTools → Rendering → “Paint flashing” to spot non-composited repaints.
  3. Verify the consumer subtree isn’t re-rendering on every progress tick.

Gesture conflicts with native scroll

  1. Pull-to-refresh only fires at scrollTop === 0.
  2. Edge-swipe only fires inside the 20 px left zone.
  3. Sheet swipe-to-dismiss respects axis (pan-x or pan-y).

Keyboard shortcut doesn’t work

  1. Ensure the swipeable container is focusable (tabIndex={0} or focusable child).
  2. Verify no parent calls event.stopPropagation() on key events.
  3. 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