Skip to content

Mobile Touch Targets, Safe Areas & Gestures

Loquent’s Capacitor-wrapped mobile app follows Apple HIG iOS 17/18 standards for touch targets (≥44×44pt), safe area insets, and core gestures. All mobile-specific UI is gated behind Tailwind breakpoints — desktop remains unaffected.

The tailwind.css file defines utilities that map to env(safe-area-inset-*) values. Use these on elements that sit near screen edges.

@utility pt-safe { padding-top: env(safe-area-inset-top, 0px); }
@utility pb-safe { padding-bottom: env(safe-area-inset-bottom, 0px); }
@utility pl-safe { padding-left: env(safe-area-inset-left, 0px); }
@utility pr-safe { padding-right: env(safe-area-inset-right, 0px); }
@utility pb-safe-kb {
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--kb-h, 0px));
}
ScenarioClassWhy
Fixed/sticky element pinned to bottompb-safe-kbClears home indicator + keyboard
Centered modal backdroppb-safe-kbLifts centered card above indicator
App shell left/right edges (landscape)pl-safe pr-safeAvoids side notch overlap
Content inside an already-safe parentNoneDon’t double-pad

Apply min-h-11 md:min-h-0 to interactive elements that fall below 44px on mobile. The md: modifier restores desktop density at ≥768px.

button {
class: "flex items-center gap-2 min-h-11 md:min-h-0 px-3 py-2 \
rounded-md hover:bg-muted active:bg-muted/70",
onclick: move |_| { /* action */ },
Icon { size: 20 }
span { "Label" }
}

Add pressed states for tactile feedback:

ElementClass
Card rowsactive:bg-muted/70
Accent rowsactive:bg-accent/40
Table rowsactive:bg-muted/70
EntityCardactive:translate-y-0 active:bg-muted/40

The use_pull_to_refresh hook adds iOS-style pull-to-refresh on scrollable containers. It activates only when scrollTop === 0 and triggers a Light haptic at the 70px threshold.

use crate::mods::mobile::{use_pull_to_refresh, PullToRefreshIndicator};
pub fn use_pull_to_refresh(
scroll_container_id: &'static str,
indicator_id: &'static str,
on_refresh: Callback<()>,
)
#[component]
pub fn MyListView() -> Element {
let resource = use_resource(|| async { fetch_items().await });
let on_refresh = use_callback(move |_: ()| {
resource.restart();
});
use_pull_to_refresh("my-list-scroll", "my-list-ptr", on_refresh);
rsx! {
div {
id: "my-list-scroll",
class: "relative overflow-y-auto max-h-screen-kb",
PullToRefreshIndicator { id: "my-list-ptr".to_string() }
for item in items {
ItemRow { item }
}
}
}
}
  1. User drags down from scrollTop === 0
  2. Indicator slides in with rubber-band resistance (factor 0.55, capped at 100px)
  3. At 70px threshold — Light haptic fires (once per gesture)
  4. Release past threshold → on_refresh callback fires, resource refetches
  5. Indicator snaps back with a 280ms iOS spring (cubic-bezier(0.16, 1, 0.3, 1))

The indicator renders with md:hidden — invisible on desktop. The hook coexists with infinite scroll because it only activates at scrollTop === 0.

SwipeableRow wraps list row content and reveals trailing action tiles on horizontal swipe — like iOS Mail or Reminders.

use crate::mods::mobile::{SwipeAction, SwipeableRow};
#[derive(Clone, PartialEq)]
pub struct SwipeAction {
pub label: String, // Short label below the icon
pub icon: Element, // Lucide icon — no width/height classes
pub bg_class: String, // e.g. "bg-destructive"
pub fg_class: String, // e.g. "text-white"
pub on_tap: Callback<()>,
}
let complete = SwipeAction {
label: "Done".into(),
icon: rsx! { Check {} },
bg_class: "bg-status-success".into(),
fg_class: "text-white".into(),
on_tap: Callback::new(move |_| mark_complete(item_id.clone())),
};
let delete = SwipeAction {
label: "Delete".into(),
icon: rsx! { Trash2 {} },
bg_class: "bg-destructive".into(),
fg_class: "text-destructive-foreground".into(),
on_tap: Callback::new(move |_| delete_item(item_id.clone())),
};
rsx! {
SwipeableRow {
trailing_actions: vec![complete, delete],
Link { to: Route::ItemDetail { id }, ItemCard { item } }
}
}

Each action tile is 80px wide. Thresholds scale with the number of actions:

ThresholdValueBehavior
Revealpanel_width / 2Snap to revealed state
Commit previewpanel_width × 1.5Last action expands, others fade
Commitpanel_width × 2Medium haptic, auto-fires last action

Convention: The last action in the array is the commit lane — it fires on full-swipe. Order actions from least to most destructive: [Complete, Delete].

The first 8px of pointer movement determines the gesture direction:

  • Horizontal (|dx| > |dy|) → swipe gesture locks in
  • Vertical (|dy| > |dx|) → gesture cancels, scroll passes through

The wrapper renders as md:contents — it collapses to just children with no gesture handlers or action panel. No desktop regressions.

Action tiles include depth cues:

  • Top highlight: bg-gradient-to-b from-white/15 to-transparent
  • Bottom shadow: bg-gradient-to-t from-black/10 to-transparent
  • Hairline border: ring-1 ring-inset ring-white/10

When a row is open, tapping the row body closes it (with stop_propagation) instead of navigating.

Both gestures use haptic_impact() from src/ui/haptic_ui.rs:

EventHapticWhen
Pull-to-refresh thresholdLightDrag crosses 70px
Swipe reveal thresholdLightSwipe crosses half panel width
Full-swipe commitMediumSwipe crosses 2× panel width

Haptics call Capacitor.Plugins.Haptics.impact() — no-op on web.

All mobile-specific UI uses CSS breakpoints, not runtime checks:

// Pull-to-refresh indicator — hidden on desktop
class: "md:hidden ..."
// SwipeableRow wrapper — collapses on desktop
class: "md:contents ..."
// Touch target floor — auto height on desktop
class: "min-h-11 md:min-h-0"

The only runtime platform check is use_capacitor_back_button in src/shared/capacitor/back_button.rs, which activates Android hardware back navigation. iOS swipe-back is handled natively by the OS.