YouBothAgent▾
You — Business rules and flows you own. Read these yourself.
Both — Know the idea; your agent follows the details.
Agent — Conventions and references your agent follows. Look up as needed.
General▾
Interface▾
Observability▾
Page Transitions
Your app runs inside a native shell, yet every screen change lands like a web page swap. The user cannot tell whether they went deeper or sideways, and the back button is the only way out.
Give the page a
transition in .config(). The mobile app shell then animates the change the way a native app does, and a browser plays it as a view transition.Words used on this page
TermDescription
Route depth
Path segments after the language and basePath:
/chat is 1, /chat/[chatId] is 2.Safe area
Space the device itself covers, such as the notch, the status bar and the home indicator.
Inset
Space the app reserves for its own bars, such as a navbar on top or a composer below.
keyboard accessory layer
A layer that rides on top of the software keyboard and moves with it.
Set the transition
A detail page that slides in over its list:
apps/myapp/page/article/[articleId].tsx
- Five values.
stack,bottomUp,fade,scaleOutandnone. - The value also picks the drag. Only
stackandbottomUpcome with a real back gesture. - The web plays it too. On a client navigation between SSR pages, a push plays the target route's transition and going back plays the one being left in reverse, while the chrome holds still. Reduced motion, or a browser without view transitions, just swaps.
What each one looks like
stack
Pushes a page over the current one. Use it for drill-down screens such as detail, edit or settings.
bottomUp
Opens a focused screen from the bottom that drags down to close. Use it for modal-like flows such as compose, picker or camera.
fade
Switches context without suggesting a deeper level.
scaleOut
A small scale motion. It is the Android default for deeper routes.
Platform defaults
Leave
transition out and the platform and route depth pick one for you:Transition
iOS
depth ≥ 2
Android
depth ≥ 2
Web · Root
Drag to go back
stack
✓
Slides in from the right.
bottomUp
Rises from the bottom.
No drag
scaleOut
✓
Scales up slightly into place.
fade
Cross-fades between pages.
none
✓
Swaps instantly with no animation.
✓Default hereOnly when written
The iOS and Android columns are routes at depth 2 or more. The last column covers the web at any depth and every platform at depth 1 or less.
The Back Gesture
Leave
gesture to the platform default unless the page has a reason not to. iOS turns it on below the root; Android and the web leave it off, as their users expect.How the value is decided
- Written wins. A
gesturewritten anywhere in the layout chain is used as-is. - No animation, no drag. Left unwritten, a page whose
transitionisnonegetsfalse, because there is nothing for the drag to move. - Otherwise the platform decides.
trueon iOS at route depth 2 or more,falseeverywhere else.
The two drags
stack
- Drag to the right, starting anywhere on the page.
- Goes back past a third of the screen width, or on a quick flick even if it travelled less.
- Hides the keyboard only once the touch is read as a drag.
bottomUp
- Drag down, starting near the top of the screen.
- Closes past half the screen width; a shorter drag snaps back.
- Hides the keyboard as soon as the drag starts.
- Intent before movement. A
stacktouch stays pending until it travels 8px, then locks to drag or scroll by whichever axis moved clearly more (1.25×). - Scrolling costs nothing. While the touch is pending, nothing closes and the keyboard stays up.


gesture: true does nothing on a fade, scaleOut or none page. Those transitions attach no drag handler at all.The Frame Config
One
.config() object sets the whole page frame: animation, gesture, reserved space and caching. Write it on a layout and every route under it inherits it.transition"none" | "fade" | "bottomUp" | "stack" | "scaleOut"default iOS stack · Android scaleOut · none on web and at depth ≤ 1
The animation played when this route is entered.
gesturebooleandefault true on iOS at depth ≥ 2, else false
Drag to go back, attached only by the
stack and bottomUp transitions.topInsetnumber | boolean | { base, sm?, md?, lg?, xl?, "2xl"? }default the chrome's height in the app · 0 on web
Space reserved for a top bar: px, or px per breakpoint from its width up;
true means 48px.bottomInsetnumber | boolean | { base, sm?, md?, lg?, xl?, "2xl"? }default the chrome's height in the app · 0 on web
Space reserved for a bottom bar, in the same forms and with the same 48px meaning for
true.safeAreaboolean | "top" | "bottom" | { top?, bottom?, android? }default iOS true · Android { android: "auto" } · web false
Device insets to reserve;
"top" or "bottom" keeps one side. The web needs fitCover.safeArea.android"auto" | "edge-to-edge" | "none"default "auto"
How Android measures the safe area;
none reserves nothing.cachebooleandefault true at depth ≤ 1, else false
Keeps one page for the route, mounted in a hidden cache layer after you navigate away; its effects stop until it is shown again.
topSafeAreaColorstringdefault the background color
CSS color painted behind the top safe-area strip.
bottomSafeAreaColorstringdefault the background color
CSS color painted behind the bottom safe-area strip.
- The closest config wins. A page's value overrides its layouts', while a
safeAreaobject merges key by key. - An inset can follow the width.
.config({ topInset: { base: 48, md: 0 } })reserves a phone-only bar, each key from its own width up. In the app, an inset the route leaves out takes the height of the chrome it renders. On the web,safeAreapads with the browser's safe area once the root layout sets.viewport({ fitCover: true }). - Top-level routes stay still. At depth 1 or less every platform defaults to
none, no gesture andcache: true, so tabs switch instantly. - A page nobody sees runs no effects. A cached page, or one left without a transition, keeps its state and DOM but stops its effects until shown again. The page under a
stacktransition stays live for a swipe back, so bind a camera, a poll or a key handler withusePageFocusEffectfromakanjs/webkit. - Every history entry is a page of its own. A push to the route you are on mounts a new page over the old one, which waits under it with its state; a replace updates the page in place. A
cacheroute stays one page for the whole session. - The stack outlives a reload. After a reload, or a WebView crash, back walks the same stack as before.
- On Android, back is the page's only while it has somewhere to go. At the index with nothing under it, the system takes back, and the app stays warm instead of quitting.
The frame as CSS variables
The resolved numbers are published as CSS custom properties, so a component can reserve the same space as the page without any JavaScript:
VariableDescription
--akan-top-safe-area
Top safe area the page reserved, in px.
--akan-bottom-safe-area
Bottom safe area the page reserved, in px.
--akan-top-inset
The
topInset in force at this width, in px.--akan-bottom-inset
The
bottomInset in force at this width, in px.--akan-page-padding-top
Top safe area plus top inset: the top padding a page body needs.
--akan-page-padding-bottom
Bottom safe area plus bottom inset: the bottom padding a page body needs.
--akan-content-height
The usable height between the page's top and bottom chrome; on the web it follows the keyboard.
--akan-keyboard-inset
Web only: the on-screen keyboard's height over the page bottom, in px.
Tailwind reads them directly:
h-(--akan-bottom-inset) sizes a bar to exactly the space the page reserved.The Keyboard Inset
A chat input, a comment box, a support composer: anything pinned to the bottom must move with the software keyboard. Every platform reports the keyboard differently.
- Reserve the bar's space with
bottomInsetin.config(). - Wrap the composer in
Layout.BottomInsetmarkedkeyboardSticky. Akan moves it into the keyboard accessory layer for you. - Keep the messages in place with
contentAnchor="bottom", covered in the next section.
A chat page with all three steps:
apps/myapp/page/chat/_index.tsx
- The declared height wins. With
bottomInsetin.config(),BottomInsetuses that height; without it, it measures its own content. - Match the bar to the reservation with
h-(--akan-bottom-inset), so the bar and the reserved space never disagree. - The web gets it too. On a server-rendered page the sticky bar rides above the keyboard as well. For a layout of your own,
useVisualViewportFrame()fromakanjs/webkitreturns{ keyboardHeight, height, offsetTop }of the part left visible.
Where the keyboard height comes from
Akan reads the height from one of three sources, and the source decides how accurate the offset is:
SourceDescription
native
The native runtime's keyboard plugin reported the exact height as the keyboard began to open.
visualViewport
How much the visible viewport shrank; Android prefers it, elsewhere it fills in for the plugin.
fallback
Neither reported a height, so it is 0: the keyboard is closed or could not be measured.
Keyboard state
In the mobile app shell,
useCsr().frameLayout.keyboard carries three flags next to the height:FieldDescription
sticky
The route has at least one
keyboardSticky slot; when false, the keyboard layer is hidden.frozen
A page transition is running, so the offset is held at 0 to keep out of its way.
visible
A height is present and
frozen is not set; branch on this, not on the height alone.Anchoring The Content
Moving the composer is half the job; the messages above it must stay where they were. That is
contentAnchor="bottom": it keeps the scroll position's distance from the bottom while the viewport resizes, the way a messenger does.Android
The WebView frame stays still while Akan applies the keyboard offset, so the composer rides the keyboard instead of jumping above it.
iOS
The
BottomInset follows the native keyboard animation, and the messages keep their distance from the composer.The composer inside the
BottomInset is an ordinary Zone with a store-driven field:apps/myapp/lib/chatMessage/ChatMessage.Zone.tsx
keyboardStickymoves theBottomInsetinto the keyboard accessory layer so it follows the keyboard.contentAnchor="bottom"keeps the bottom distance while the viewport resizes.bottomis the only value, and it works only together withkeyboardSticky.- The page stays a server component. To start the chat scrolled to the bottom, add a tiny client helper inside the page or Zone that targets the Akan page content container.
A helper that scrolls the page it sits in to the bottom once:
apps/myapp/ui/Chat/ScrollToBottomOnMount.tsx
- Search upward, not across the document. During a transition several pages stay mounted, each with
.akan-page-content, sodocument.querySelectorcan pick the wrong one;closest()finds this page's. - Drop it anywhere in the page.
<ScrollToBottomOnMount />renders a hidden marker and nothing else.


contentAnchor is a BottomInset option, not a .config() option. Ordinary forms keep the default keyboard behaviour; only messenger-style screens opt in, locally.