Navigation & UI
Your links already work in the app, and Android Back, deep links and transitions behave the way people expect from a native app. Add a few attributes to plain HTML and you get safe areas, a tab bar and bottom sheets in every framework.
#How navigation works
You do not change your routes or links. The app keeps each page change all-or-nothing, so a slow or failed load never leaves half a page on screen.
#Links
Ordinary anchors are all you need. data-absolute-link changes how one behaves, and outside the app each link still works as a normal link.
<a href="/orders/42">Order 42</a>
<a href="/checkout/review" data-absolute-link="replace">Review</a>
<a href="/orders" data-absolute-link="back">Back to orders</a>
<a href="https://help.example.com" data-absolute-link="external">Help</a>| Link | In the app |
|---|---|
<a href="/orders"> | Opens the page in the app. Links to your production origin work the same way. |
data-absolute-link="replace" | Opens the page in place of the current history entry. |
data-absolute-link="back" | Closes an open sheet, otherwise goes back. href is the fallback without JavaScript. |
data-absolute-link="external" | Opens an http or https page in the system browser. |
<a target="_blank"> | Any link with a target is left to the WebView. |
#Android Back
The hardware Back button and the back gesture are handled for you, in this order:
#Safe areas and keyboard
Every page in the app gets CSS variables and attributes on <html> describing the screen, kept up to date through rotation, the keyboard and page changes. Nothing is padded for you, so an existing responsive layout looks the same until you opt in. The same CSS works in the browser, in Capacitor and in Expo; the fallbacks apply on the web.
.app-shell {
min-height: var(--absolute-available-height, 100dvh);
padding:
var(--absolute-safe-area-inset-top, 0)
var(--absolute-safe-area-inset-right, 0)
var(--absolute-safe-area-inset-bottom, 0)
var(--absolute-safe-area-inset-left, 0);
}
:root[data-absolute-keyboard='visible'] .checkout-bar {
bottom: var(--absolute-keyboard-height);
}
:root[data-absolute-network='offline'] .sync-badge {
display: inline-flex;
}| CSS variable | Value |
|---|---|
--absolute-safe-area-inset-top | Also -right, -bottom and -left: the notch, status bar and home indicator |
--absolute-keyboard-height | Height of the on-screen keyboard |
--absolute-viewport-height | Also --absolute-viewport-width |
--absolute-available-height | Height left for content once the keyboard is open |
| Attribute on <html> | Values |
|---|---|
data-absolute-mobile | Present in the app |
data-absolute-runtime | capacitor, expo, web or test |
data-absolute-platform | ios, android and so on |
data-absolute-form-factor | phone, tablet, desktop or unknown |
data-absolute-keyboard | visible or hidden |
data-absolute-network | online or offline |
data-absolute-connection | wifi, cellular, ethernet, unknown or none |
data-absolute-reduced-motion | reduce or no-preference |
The status and navigation bars follow the system’s light or dark appearance; systemBars from @absolutejs/devices changes them.
#App layout
Mark up a header, a scrolling main area and a tab bar, and the app handles safe areas, scrolling, the active tab and transitions. These are attributes on ordinary elements, not components, so the same markup works in JSX, Svelte, Vue and Angular templates, and in HTML and HTMX pages. Colors, type and spacing stay in your CSS.
<div data-absolute-app-shell>
<header data-absolute-app-header>
<h1>Account</h1>
<button data-absolute-sheet-open="filters">Filters</button>
</header>
<main data-absolute-app-main>
<section data-absolute-navigation-stack>
<!-- your page content -->
</section>
</main>
<nav data-absolute-tab-bar aria-label="Primary">
<a href="/home">Home</a>
<a href="/orders" data-absolute-tab-match="prefix">Orders</a>
<a href="/account" data-absolute-tab-match="prefix">Account</a>
</nav>
<dialog id="filters" data-absolute-sheet aria-labelledby="filters-title">
<h2 id="filters-title">Filters</h2>
<button data-absolute-sheet-close>Done</button>
</dialog>
</div>| Attribute | On | Does |
|---|---|---|
data-absolute-app-shell | Container | Fills the available height, inside the safe areas |
data-absolute-app-header | Header | Clears the status bar and notch |
data-absolute-app-main | Main | The scrolling region; its position is restored on Back |
data-absolute-navigation-stack | Page view | Slides forward and back with View Transitions |
data-absolute-tab-bar | nav | Clears the home indicator and sets aria-current="page" on the active tab |
data-absolute-tab-match="prefix" | Tab link | Keeps the tab active on nested routes; exact match otherwise |
data-absolute-sheet | dialog | A bottom sheet with focus kept inside it |
data-absolute-sheet-open="id" | Button or link | Opens the sheet with that id |
data-absolute-sheet-close | Control in a sheet | Closes the sheet and returns focus to what opened it |
Tab bars stay navigation landmarks and tabs stay links; AbsoluteJS does not add tablist roles, which describe tabs within one page.
#Sheets
A sheet is a <dialog> with data-absolute-sheet. It opens from the bottom with focus moved inside, and closes from its close control, Escape, a tap on the backdrop, Android Back or a back link, always before the page changes. One sheet is open at a time, and focus returns to the control that opened it.
#Scroll, focus and forms
Back and Forward bring a page back as it was: form values, selection, open disclosures, focus and scroll position. This state lives only in memory. It is never written to history, storage or Sync, and password, file, hidden, card-number and one-time-code fields are left out entirely. Data that must survive the app being closed belongs in your app state or Sync.
<!-- Values in this form reset when the page is opened again -->
<form data-absolute-navigation-preserve="off">…</form>
<!-- Keep this list's scroll position on Back and Forward -->
<ul data-absolute-scroll-restoration>…</ul>
<!-- Focus this element when the page opens -->
<h2 data-absolute-navigation-focus tabindex="-1">Your orders</h2>| Attribute | Effect |
|---|---|
data-absolute-navigation-preserve="off" | Values inside reset when the page is recreated |
data-absolute-scroll-restoration | Restores this element’s scroll position too (the main region is automatic) |
data-absolute-navigation-focus | Receives focus on a new page, ahead of the first h1 in main, the first h1 and main |
An element with autofocus takes priority over all of them.
#Events
Every event is dispatched on window and typed in TypeScript, so event.detail needs no casting.
addEventListener('absolute:navigation-change', (event) => {
const { direction, from, to } = event.detail;
analytics.track('screen', { direction, from, to });
});
addEventListener('absolute:sheet-change', (event) => {
if (!event.detail.open) refreshFilters(event.detail.id);
});
addEventListener('absolute:adaptive-shell-change', (event) => {
const { keyboard, network, platform } = event.detail;
setCompactMode(keyboard.visible || platform.formFactor === 'phone');
setOffline(!network.connected);
});| Event | Detail | Fires |
|---|---|---|
absolute:navigation-change | { direction: "forward" | "back" | "replace", from, to } | After a new page is showing |
absolute:sheet-change | { id, open } | When a sheet opens or closes |
absolute:adaptive-shell-change | { availableHeight, keyboard, network, platform, viewportHeight, viewportWidth } | On rotation, keyboard, network and safe-area changes |
absolute:shell-rendered | none | Once, when the first page has painted |
#In the browser
The app installs all of this for you. To use the same layout, sheets and Back handling on your website, call installAbsoluteMobileUiPrimitives from any client entry. It returns navigate, refreshDocument, requestBack and dispose; requestBack returns true when an open sheet handled it.
import {
closeAbsoluteMobileSheet,
installAbsoluteMobileUiPrimitives,
openAbsoluteMobileSheet
} from '@absolutejs/absolute/mobile/ui';
const mobileUi = installAbsoluteMobileUiPrimitives();
openAbsoluteMobileSheet('filters');
closeAbsoluteMobileSheet('filters');
mobileUi.requestBack();