AzNavRail

A contemptably stubborn if not dictatorially restrictive navigation rail/menu–I call it a renu. Or maybe a mail. No, a navigrenuail–for Jetpack Compose with a streamlined, DSL-style API.

This “navigrenuail” provides a vertical navigation rail that expands to a full menu drawer. It is designed to be “batteries-included,” providing common behaviors and features out-of-the-box to ensure a consistent look and feel across applications.


Platform parity note. The Kotlin modules (aznavrail for Android, aznavrail-cmp for Compose Multiplatform) are the reference implementation. The React port (aznavrail-react) is versioned separately and currently lags them — the drop-down trigger set, unattached hosts, azItemState and azPopup are Kotlin-only. See Capabilities & Limitations.

📚 Documentation


🆕 What’s new

The most recent additions, and where each is documented in full:

Feature What it is Section
Badges azItemState(id, badge = "3") drops a count on any item’s corner — dissolving after a second, or persistentBadge = true to stay. Same call sets a per-item spinner and an alert glyph. Per-Item Badges & Loading
Three highlights An item can be lit three different ways — active (where you are), focus (what you are touching), and a secondary highlight that is entirely yours. azTheme sets the palette, azHighlight overrides it per item. The three highlights
Windows that move and fold Every panel the library floats — popups, hidden menus — is an AzWindow: drag it by its bar, fold it to that bar, close it. Windows (AzWindow)
About, exactly once The library tracks every surface that offers an About affordance and draws it in only one of them, so a rail plus a drop-down no longer means About three times over. In-App About Reader
About, already loaded The reader’s docs and carousel are fetched in the background as the rail composes, so About opens populated instead of spinning. In-App About Reader

🚀 Setup

Add JitPack to your settings.gradle.kts:

Features

AzNavRail for Android (Jetpack Compose)

Setup

To use this library, add JitPack to your settings.gradle.kts:

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        mavenCentral()
        maven { url = uri("https://jitpack.io") }
    }
}

Add the dependency to your app’s build.gradle.kts:

dependencies {
    implementation("com.github.HereLiesAz.AzNavRail:aznavrail:VERSION") // Replace VERSION with the latest release
}

Compose Multiplatform

Targeting more than Android? The aznavrail-cmp module is a Compose Multiplatform port of the same rail — one commonMain source set across Android, Desktop (JVM), Web (wasmJs), and iOS:

commonMain.dependencies {
    implementation("com.github.HereLiesAz.AzNavRail:aznavrail-cmp:VERSION")
}

See the module README for the CompositionLocal wiring (there’s no AzHostActivityLayout off-Android) and the aznavrail-cmp-demo runnable desktop + web sample.

Troubleshooting: No matching variant … was found

If your Gradle sync fails with something like:

Could not resolve com.github.HereLiesAz.AzNavRail:aznavrail-cmp:VERSION.
  Required by: project :app
   > No matching variant of com.github.HereLiesAz.AzNavRail:aznavrail-cmp:VERSION was found.

…you’re pulling a Kotlin Multiplatform artifact into a plain Android/JVM project. The multiplatform module (aznavrail-cmp) publishes Gradle Module Metadata with a variant per platform, and a non-KMP consumer can’t select one through JitPack’s metadata redirect. Switch to the Android artifact directly:

Also make sure you’re not using the old single-module coordinate com.github.HereLiesAz:AzNavRail:VERSION — it predates the multi-module split and no longer resolves. The module lives under group com.github.HereLiesAz.AzNavRail.


🛠️ The Golden Sample

This is the standard, validated way to initialize AzNavRail. It must be wrapped in AzHostActivityLayout.

import com.hereliesaz.aznavrail.*

@Composable
fun SampleScreen() {
    val navController = rememberNavController()

    AzHostActivityLayout(
        navController = navController,
        initiallyExpanded = false
    ) {
        // 1. CONFIGURATION
        azConfig(
            dockingSide = AzDockingSide.LEFT,
            packButtons = true, // Tightly pack rail items
            displayAppName = true
        )

        azTheme(activeColor = Color.Cyan)

        // 2. NAVIGATION ITEMS
        azRailItem(id = "home", text = "Home", route = "home", content = Icons.Default.Home)
        azRailItem(id = "profile", text = "Profile", route = "profile")

        // 3. MENU ONLY ITEMS
        azMenuItem(id = "settings", text = "Settings", route = "settings")

        // 4. ONSCREEN CONTENT
        // Use 'onscreen' to define your UI. 
        // Layout rules (safe zones, padding) are enforced automatically.
        onscreen(alignment = Alignment.Center) {
            AzNavHost(startDestination = "home") {
                composable("home") { Text("Home Screen") }
                composable("profile") { Text("Profile Screen") }
                composable("settings") { Text("Settings Screen") }
            }
        }
    }
}

✨ Features & Basics

AzHostActivityLayout Configuration

AzHostActivityLayout accepts several parameters to customize its behavior:

AzHostActivityLayout Layout Rules

AzHostActivityLayout enforces a “Strict Mode” layout system:

  1. Rail Avoidance: No content in the onscreen block will overlap the rail. Padding is automatically applied based on the docking side.
  2. Vertical Safe Zones: Content is restricted from the top 10% and bottom 10% of the screen.
  3. Automatic Flipping: Alignments passed to onscreen (e.g., TopStart) are automatically mirrored if the rail is docked to the right.
  4. Backgrounds: Use the background(weight) DSL to place full-screen content behind the UI (e.g., maps, camera feeds). Backgrounds ignore safe zones.
  5. Edge-to-edge (Android 15 / SDK 35): The host puts the Activity edge-to-edge itself (enableEdgeToEdge()), and resolves every safe zone against the system bars union the display cutout — a camera cutout can occlude an edge with no system bar on it, which is exactly the landscape short edge the rail docks against. AzSafeZones therefore carries start/end as well as top/bottom. Every window the library adds to the WindowManager sets LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS — the only value Android 15 left non-deprecated; if you override AzNavRailWindowService.windowParams, keep that assignment. See the Complete Guide §1.1.

Pages (Z-ordering)

Both onscreen(...) and background(...) accept a page: Float (default 0f). Pages are a simple Z-ordering system layered on top of standard Compose positioning:

onscreen(alignment = Alignment.Center, page = 0f) { /* base layer */ }
onscreen(alignment = Alignment.Center, page = 1f) { /* drawn BEHIND page 0, can overlap it */ }
background(weight = 0, page = 2f) { /* a background layer, even further back */ }

Help Overlay (Help Mode)

AzNavRail includes an interactive “Help Mode” (formerly Info Screen), ideal for onboarding or help sections.

AzTextBox and AzForm

AzTextBox is a text input field. AzForm is a container that groups multiple AzTextBox fields, managing them as a single entity with one submit button.

Features

AzLoad Animation

React Quick Start

import { AzNavRail, AzNavItem, AzButtonShape, AzDockingSide } from '@HereLiesAz/aznavrail-react';

export default function App() {
  const [expanded, setExpanded] = useState(false);

  const items: AzNavItem[] = [
    {
      id: "home",
      text: "Home",
      isRailItem: true,
      onClick: () => console.log("Home clicked"),
      shape: AzButtonShape.CIRCLE,
    },
    {
      id: "settings",
      text: "Settings",
      isRailItem: true,
      onClick: () => console.log("Settings clicked"),
      shape: AzButtonShape.RECTANGLE,
    }
  ];

  return (
    <View style=>
      <AzNavRail
        appName="My App"
        appIcon={require('./assets/icon.png')}
        items={items}
        expanded={expanded}
        onToggleExpand={() => setExpanded(!expanded)}
        settings=
      />
      <View style=>
        {/* Main Content */}
      </View>
    </View>
  );
}

1. Strict Layout System

AzHostActivityLayout enforces a “Constitution” for your UI to ensure consistency and usability:

2. Navigation Items

3. Interactive Components

Manage state directly in the rail without leaving the context.

AzNavRail(...) {
    azAdvanced(
        isLoading = true // Shows the AzLoad animation in the center of the screen
        // ...
    )
}

React Implementation:

import { AzNavItem } from '@HereLiesAz/aznavrail-react';

const items: AzNavItem[] = [
    {
        id: "power",
        isRailItem: true,
        isToggle: true,
        isChecked: isPowerOn,
        toggleOnText: "Power On",
        toggleOffText: "Power Off",
        onClick: () => setPowerOn(!isPowerOn),
        // ...
    },
    {
        id: "mode",
        isRailItem: true,
        isCycler: true,
        options: ["Auto", "Cool", "Heat"],
        selectedOption: currentMode,
        // ...
    }
];

Standalone Usage

You can also use AzLoad directly in your composables.

React Implementation:

import { AzLoad } from '@HereLiesAz/aznavrail-react';

<AzLoad size={48} color="#6200EE" />

Standalone Buttons

The AzButton component (and AzToggle, AzCycler) can be used independently of the rail.

AzButton(
    onClick = { /* ... */ },
    text = "Save",
    modifier = Modifier.fillMaxWidth(), // Now supports modifiers
    shape = AzButtonShape.RECTANGLE,
    enabled = true, // Can be disabled
    isLoading = false, // Shows loading spinner without resizing button
    contentPadding = PaddingValues(16.dp) // Custom padding
)

AzRoller

The AzRoller component is a versatile dropdown that behaves like a slot machine but also supports typing and filtering. It extends the functionality of AzTextBox with a unique split-click interaction model.

AzRoller(
    options = listOf("Cherry", "Bell", "Bar"),
    selectedOption = "Cherry",
    onOptionSelected = { /* handle selection (String) */ },
    hint = "Select Item",
    enabled = true,
    isError = false
)

React Implementation:

import { AzRoller } from '@HereLiesAz/aznavrail-react';

<AzRoller
    options={["Cherry", "Bell", "Bar"]}
    selectedOption="Cherry"
    onOptionSelected={(option) => { /* handle selection */ }}
    hint="Select Item"
    enabled={true}
/>

Hierarchical Navigation

AzNavRail supports hierarchical navigation with host and sub-items. This allows you to create nested menus that are easy to navigate.

React Implementation:

const items: AzNavItem[] = [
    {
        id: "host-1",
        text: "Host Item",
        isRailItem: true,
        isHost: true,
        isExpanded: isHost1Expanded,
        onClick: () => setHost1Expanded(!isHost1Expanded),
        // ...
    },
    {
        id: "sub-1",
        text: "Sub Item",
        isRailItem: true,
        isSubItem: true,
        hostId: "host-1",
        // ...
    }
];

Unattached Hosts (azUnattachedHostItem)

A host item does not have to live in the rail. azUnattachedHostItem draws one on its own somewhere else on the screen; tapping it unfolds its sub-items inline beneath it, exactly as they would have unfolded inside the rail. Sub-items attach the usual way — hostId — and sub-hosts still nest to any depth. The host and its whole subtree leave both the rail strip and the drawer.

anchor Where it parks
AzUnattachedAnchor.OPPOSITE (default) Side opposite the rail, level with the rail’s own items.
AzUnattachedAnchor.BOTTOM Bottom of the screen, opposite side.
AzUnattachedAnchor.FLOATING Draggable; its position is persisted across launches.

OPPOSITE/BOTTOM hosts sharing an anchor stack into a fixed column with the rail’s own spacing. FLOATING hosts each float and dock independently instead:

Each host’s own resting spot is persisted per-launch; rail-to-rail attachments are not.

Long lists scroll. When an expanded host’s sub-items run past the room it has — the window below the host, minus the safe insets (for OPPOSITE/BOTTOM, also minus one button per host stacked after it; for FLOATING, down to the bottom safe inset from wherever the rail sits) — the sub-items scroll under the host, which itself stays fixed. Unfolded relocatable sub-hosts are part of the same list. With no overflow nothing changes: no clipping, no scroll gesture. A swipe over the list scrolls it and clicks nothing; a tap still clicks. Long-press-then-drag reordering still works inside a scrolled list, and dragging an item into the list’s top or bottom edge band auto-scrolls it so the item can be dropped beyond the visible window. On expansion, the active item (current route, active classifier or last tapped) is scrolled into view.

azUnattachedHostItem(id = "tools", text = "Tools", anchor = AzUnattachedAnchor.FLOATING)
azRailSubItem(id = "measure", hostId = "tools", text = "Measure") { measure() }
azRailSubToggle(
    id = "grid", hostId = "tools",
    isChecked = grid, toggleOnText = "Grid On", toggleOffText = "Grid Off",
) { grid = !grid }

azRailRelocItem attaches by hostId here too — a Procreate-style layers panel is exactly this: each layer row is an azRailRelocItem(hostId = "tools"). Tap, long-press hidden menus and long-press-drag reordering all work. A group row is an azRailRelocSubHostItem: a host that is one draggable slot together with its children (see the complete guide, §5).

Per-Item Badges & Loading (azItemState)

Every item builder takes badge / persistentBadge / isLoading, and any declared item — rail, menu, sub-item, host, unattached host, nested-rail child — can be decorated by id afterwards:

azRailItem(id = "sync", text = "Sync") { startSync() }
azItemState(id = "sync", isLoading = syncing, badge = pending.takeIf { it > 0 }?.toString())

Loading is per item — the button spins its own AzLoad, scaled to itself and tinted to its own colour, while the rest of the rail stays live. Null fields leave the item’s existing value alone, so setting the badge does not clear the spinner.

The three highlights (active, focus, secondary)

An item can be lit three ways, and they answer three different questions. Picking the right one is mostly a matter of answering the question the highlight is for:

Highlight The question it answers What lights it
Active Where am I? The item’s route is the current destination, or one of its classifiers is in azConfig(activeClassifiers = …).
Focus What am I touching? The item is pressed, or it was the last one tapped and carries no route of its own — a toggle, a cycler, an action.
Secondary Whatever you decide. Only you: azItemState(id, secondary = true) or azConfig(secondaryClassifiers = …). The library never lights it.

Set the palette once on the rail, and override any of the three on a single item:

azTheme(
    activeColor = Color.Cyan,     // where you are
    focusColor = Color.White,     // what you are touching  (unset = same as activeColor)
    secondaryColor = Color(0xFFFFB300), // yours             (unset = the rail's accent)
)

azRailItem(id = "record", text = "Rec")
azHighlight(id = "record", active = Color.Red)   // this one goes red when it is the live screen
azItemState(id = "record", secondary = armed)    // …and amber while merely armed

How to pick them. activeColor is the loudest thing on the rail, because “where you are” is the one fact the rail exists to tell you — give it the app’s accent. focusColor is a transient: it is on screen for as long as a finger is, so it wants contrast against the rail’s own colour rather than a second hue competing with the accent. Leave it unset and focus simply reuses activeColor, which is how the rail has always looked. secondaryColor should be a colour that reads as a condition rather than a place — an amber for “armed”, a green for “synced” — because the user will see it on an item that is emphatically not where they are.

Precedence, when more than one applies at once: focus beats active beats secondary. A press is the most immediate thing happening, so it wins for as long as it lasts.

Popups (AzPopup)

An AzPopup is bound to a rail item, and the two share state both ways.

val alerts = rememberAzPopupController()

AzHostActivityLayout(navController = navController) {
    azRailItem(id = "sync", text = "Sync") {
        alerts.show(itemId = "sync", title = "Syncing", message = "Talking to the server…")
    }
    azPopup(alerts) {
        Text(message ?: "")
        AzButton(onClick = { item?.setLoading(false); dismiss() }, text = "Stop")
    }
}

The body’s item handle reads the live AzNavItem and writes back to it — setBadge, setLoading, setAlert, clear — and those writes survive the DSL re-running until the popup clears them. show() with no itemId binds to the last touched rail item, so a warning raised from a background job lands on whatever the user just did.

A AzPopupKind.NOTICE / WARNING popup redraws its bound item as a yellow, rounded-corner triangle outline for as long as it is up, then restores it. The glyph is also available as an ordinary item shape, AzButtonShape.TRIANGLE.

Windows (AzWindow)

Every panel this library floats over an app is a window, and they all behave the same way:

AzPopup is drawn in one. So is the hidden menu (hiddenMenu { … } on a reloc item) — which is the panel that most needed it, since it can hold a text field, and typing into a box you cannot move off the thing you are typing about is a poor arrangement.

You can raise one yourself:

val panel = rememberAzWindowState()

AzWindow(title = "Layers", state = panel, onDismiss = { showPanel = false }) {
    Column(Modifier.padding(16.dp)) { /* anything */ }
}

AzWindowState carries the window’s position and folded state; hoist it (as above) when either has to outlive the window — a panel that reopens where the user left it. movable = false / minimizable = false drop the affordances for a window that genuinely shouldn’t have them.

Gestures the rail will and will not claim

The rail is laid out over the whole window, so what it listens for is what your app underneath does not get. It installs pointer handlers only when it can answer them, and consumes only the events it acts on:

Situation What the rail does
Menu expanded A scrim covering everything except the rail collapses it on an outside tap. It exists whenever the drawer is open; dimBehindMenu only decides whether that area is also darkened.
Drag on the rail The drag detector exists only when the rail is floating, enableRailDragging is on, or the menu is swipe-openable — and it consumes the pointer only on the branch that actually undocks the rail or moves the menu. A scroll that starts under the rail still reaches your content.
Tap on the collapsed, docked rail Only the buttons take taps. The gaps between them, and the empty strip above and below, pass through to whatever your app drew underneath.
Tap while expanded or floating The rail is a panel in its own right and swallows stray taps.
Nested rail open A tap-to-dismiss listener covers the strip only while one is open.

There is deliberately no window-wide tap listener. If the rail appears to eat a gesture your app wanted, that is a bug — please file it.

Draggable Rail (FAB Mode)

The rail can be detached and moved around the screen by long-pressing the header icon, which activates “FAB Mode”. To enable this feature, set enableRailDragging = true in the azAdvanced block.

React Implementation:

import { AzNavRailSettings } from '@HereLiesAz/aznavrail-react';

const settings: AzNavRailSettings = {
    enableRailDragging: true
};
// Pass settings to AzNavRail

AzDropdownMenu — standalone hamburger menu

A drop-down menu is a standalone widget declared with the same opinionated DSL as the rail. In AzNavRail tradition it accepts only what the rest of the library sanctions — no arbitrary panel background, offsets, or free composable escape hatch.

Its trigger is chosen with azConfig(trigger = …) from the sanctioned AzDropdownTrigger set: MoreVert (three vertical dots — the default), Hamburger, Text("Filter"), Icon(myVector) (any ImageVector / Painter / URL / Coil model), or AppIcon (the launcher icon). Its size and clip shape come from headerIconSize / headerIconShape.

By default (azConfig(triggerPlacement = AzDropdownTriggerPlacement.AUTO)) that trigger is placed next to the big screen title, above the onscreen content area, whenever the drop-down is declared inside an AzHostActivityLayout. Declare several drop-downs and their triggers line up beside each other in declaration order. A drop-down used outside a host stays inline; force either behaviour with TITLE / INLINE.

Tapping it unfolds an overlay panel of the items you declare; tapping outside, pressing back, or tapping an item folds it up.

Configure it through azConfig (mirroring the rail): design picks AzDropdownDesign.RAIL (compact rail buttons at the collapsed width, ≈100dp) or AzDropdownDesign.MENU (the default; full-width labeled rows at the expanded width, ≈160dp); dockingSide pins the panel to the LEFT or RIGHT screen edge; the app-icon headerIconShape/headerIconSize (mirroring the rail’s azTheme) and vibrate/expandedWidth/collapsedWidth round out the config. The panel drops from the trigger automatically (downward when it fits, otherwise upward). The MENU design renders rows at the rail’s menu-item text size and carries the rail’s footer (About / Feedback / @HereLiesAz, gated by showFooter), just like the expanded menu. Because the dropdown has no onscreen/host area, tapping About opens a full-screen in-app reader drawn as its own layer (when inAppAbout = true, the default); set inAppAbout = false to open the repo in a browser instead. The repo is auto-derived from the app namespace on Android, with appRepositoryUrl as an optional override (never the AzNavRail library repo).

Items are declared with azItem / azToggle / azCycler / azDivider, accepting only the rail’s sanctioned per-item knobs (color/textColor/fillColor/shape/enabled/closeOnClick). Each may carry a route which — like the rail — navigates the supplied navController, so the drop-down can drive an AzNavHost.

AzDropdownMenu(navController = navController) {
    azConfig(design = AzDropdownDesign.MENU, dockingSide = AzDockingSide.LEFT)
    azItem("Home", route = "home") { }
    azToggle(isChecked = dark, toggleOnText = "Dark", toggleOffText = "Light") { dark = it }
    azDivider()
    azItem("Sign out") { signOut() }
}

React Implementation:

import { AzDropdownMenu, AzDropdownItem, AzDivider, AzDockingSide, AzDropdownDesign } from '@HereLiesAz/aznavrail-react';

<AzDropdownMenu design={AzDropdownDesign.MENU} dockingSide={AzDockingSide.LEFT} onNavigate={go}>
  <AzDropdownItem text="Home" route="home" onClick={() => {}} />
  <AzDivider />
  <AzDropdownItem text="Sign out" onClick={signOut} />
</AzDropdownMenu>

The dropdown’s app-icon trigger carries an automatic margin, so it never sits flush against neighbouring widgets.

Kinetic Typography (Windows-Phone-7 style)

AzNavRail can animate its menu words natively — a staggered turnstile entrance/exit (each item swings 90° around the docked edge, edge-on → flat, no fade, no vertical slide), a 3D tilt-on-press, and an itemTextStyle override so the words can be big/light/wide Metro type. It’s config-driven (preset enums, no free-composable escape hatch). In FAB / floating mode, where there’s no docked edge to hinge on, the cascade degrades to a vertical up/down slide.

Default timing lets items overlap heavily: entranceDurationMs = 280 and entranceStaggerMs = 22 mean the next item begins ~22 ms after the previous begins while the previous is still animating. The stagger is rescaled by item count (AzMotion.CascadeReferenceCount), so a three-item rail folds and unfolds in the same time as a twelve-item one. One stagger tick after the last item begins, the footer (About / Feedback / @HereLiesAz) unfolds like an accordion from the top edge — the natural next beat in the cascade rhythm — so the whole rail-open motion completes as one continuous phrase.

Three surfaces animate:

All of it is on by default (the library’s signature look); opt a surface out with AzEntrance.None / AzExit.None.

Android — the rail uses azKinetics(...); the dropdown uses its own azConfig(...):

AzNavRail {
    azKinetics(
        itemEntrance = AzEntrance.Turnstile,   // default
        itemExit = AzExit.Turnstile,           // default
        tiltOnPress = true,                    // off by default on the rail (drag-safe)
        itemTextStyle = TextStyle(fontSize = 22.sp, fontWeight = FontWeight.Light),
        titleEntrance = AzEntrance.Turnstile,  // the big screen title
    )
    // … items …
}

AzDropdownMenu {
    azConfig(
        itemEntrance = AzEntrance.Turnstile,
        itemExit = AzExit.Turnstile,
        tiltOnPress = true,
        itemTextStyle = TextStyle(fontSize = 28.sp, fontWeight = FontWeight.Light),
    )
    // … items …
}

React — the rail reads kinetics from settings; the dropdown takes props:

<AzNavRail settings= … />

<AzDropdownMenu itemEntrance={AzEntrance.Turnstile} itemExit={AzExit.Turnstile} tiltOnPress
                itemTextStyle=>
  …
</AzDropdownMenu>

AzEntrance is None | Fade | SlideUp | Turnstile; AzExit is None | Fade | Turnstile; AzEasing.Wp7Decelerate is the signature snappy curve (the default easing).

Three developer knobs on azConfig (and on the standalone AzDropdownMenu’s azConfig) shape the drawer itself. They only affect the expanded-menu drawer labels — small rail-button labels are unaffected.

azConfig(
    dimBehindMenu = true,
    menuItemAlignment = AzMenuItemAlignment.SIDE, // default
    justifyMenuItems = true,                       // default
)

React:

<AzNavRail settings= … />

Sizable Header Icon

By default the app icon in the header sizes itself to the rail width. To pin it to an exact diameter, set headerIconSize (a Dp on Android, a pixel number on React):

azTheme(headerIconSize = 48.dp)
// or via the combined helper:
azSettings(headerIconSize = 48.dp)
const settings: AzNavRailSettings = { headerIconSize: 48 };

In-App About Reader

The rail’s About (?) item — and the drawer footer’s About — open a built-in, themed in-app reader split into two halves:

Repo resolution differs by platform:

The reader’s content is fetched in the background as the rail composes — the docs listing, the first document’s markdown, and the More-from-Az manifest — so opening About shows the page rather than a spinner. A reader opened while a cold-start fetch is still in flight fills itself in the moment it lands. The bottom of the page carries @HereLiesAz, Feedback and hereliesaz.com: About is where someone goes to find out who made this, so that is where the way to reach them belongs.

The carousel snaps. A flick hands focus to the next app or two and settles firmly onto it rather than coasting past a dozen; whatever the gesture leaves half-centred is pulled the rest of the way in; and tapping a card that isn’t centred brings it to the centre.

About appears exactly once. The library keeps track of every surface that could offer it — a ? rail item you declared, the automatic ?, the rail’s expanded-menu footer, a drop-down menu’s footer — and draws it only in the highest-ranked one, in that order. So an app with both a rail and a drop-down gets one About, in the rail’s menu, instead of three. Turn it off with azAbout(dedupeAbout = false) (or azConfig(dedupeAbout = false) on a drop-down) to draw About wherever it is configured.

The standalone AzDropdownMenu has no onscreen area, so its About opens as its own full-screen in-app reader (same namespace derivation; inAppAbout = false reverts to a browser link).

// Android: appRepositoryUrl is optional — the repo is derived from the namespace by default.
azConfig(/* appRepositoryUrl = "https://github.com/YourOrg/YourApp" */)  // optional override
azAbout(inAppAbout = true)   // default; set false to open the repo URL in a browser instead

The rail ends with an About (?) button — a real rail item, appended last — whenever no other surface is already offering About. With de-duplication on (the default) that means a rail with an expanded-menu footer carries About there and drops the automatic ?; a noMenu rail, which has no footer, keeps it. It persists but it is not fixed: declare your own anywhere in the item order to move, restyle, or rename it — a declared one outranks every other surface — or drop it entirely.

azAboutRailItem(id = "about", text = "?", color = Color.White)  // yours, wherever you call it
azAbout(aboutRailItem = false)                                  // or no rail item at all
<AzAboutRailItem id="about" text="?" />          {/* yours, wherever you render it */}
const settings = { aboutRailItem: false };       {/* or no rail item at all */}

Tapping the ? opens the reader; tapping it again closes it. So does tapping any other rail or menu item, or the app icon — reaching for the rail means you are done reading. The reader never covers the rail’s own gutter, so the app icon stays tappable behind it.

const settings: AzNavRailSettings = {
  appRepositoryUrl: 'https://github.com/YourOrg/YourApp', // required on web
  inAppAbout: true,
};

More from Az

A built-in carousel of the library author’s other apps, reachable from a “More from Az” entry in the About screen and/or a pinned “More” rail item. You maintain it by pasting GitHub repo links — one per line, any order — into more-from-az.json. That’s all; a GitHub Action resolves everything else and bakes a finished manifest the rail just renders:

{ "version": 1, "apps": [
  "https://github.com/HereLiesAz/AzNavRail",
  "https://github.com/HereLiesAz/CueDetat",
  "https://github.com/HereLiesAz/GraffitiXR"
] }

For each repo, .github/workflows/bump-more-from-az.yml (running server-side with the authenticated GITHUB_TOKEN):

So you never hand-format JSON, never construct a Play link, and never cut a release to add an app. Do not hand-edit version (CI-managed). The rail parser is lenient, so the list still renders in the brief window before CI bakes. Grouping is by repository, so paste order never matters.

azAbout(
    moreFromAzEnabled = true,                 // show the "More from Az" entry in the About screen
    moreRailItem = true,                      // also pin a "More" item at the bottom of the rail
    moreFromAzJsonUrl = "https://raw.githubusercontent.com/HereLiesAz/AzNavRail/main/more-from-az.json",
)
const settings: AzNavRailSettings = {
  moreFromAzEnabled: true,
  moreRailItem: true,
  moreFromAzJsonUrl: 'https://raw.githubusercontent.com/HereLiesAz/AzNavRail/main/more-from-az.json',
};

Because all resolution happens in CI (not in the app), metadata is identical on Android and web — there’s no client-side CORS limitation, and the runtime never spends GitHub’s rate-limit budget.

Reorderable Items (AzRailRelocItem)

AzRailRelocItem is a specialized sub-item that users can reorder via drag-and-drop. This feature is supported on Android, Web, and React Native.

azRailRelocItem(
    id = "reloc-1",
    hostId = "host-1",
    text = "Item 1",
    forceHiddenMenuOpen = false, // Programmatically open the hidden menu!
    onHiddenMenuDismiss = { /* Menu dismissed */ },
    onRelocate = { from, to, newOrder ->
        // Handle new order (List<String>)
    }
) {
    // Hidden Menu (Tap to select -> Long Press to open)
    listItem("Action 1") { /* ... */ }
    inputItem("Rename", initialValue = "Item 1") { newName -> /* ... */ }
}

hostId may also name an azUnattachedHostItem (Android/CMP only — see below); tap and long-press-to-open-hidden-menu work the same way there, but onRelocate only fires when hostId names a rail host, since an unattached host’s stack has no drag-to-reorder gesture of its own.

React Implementation:

import { AzRailRelocItemProps, HiddenMenuScope } from '@HereLiesAz/aznavrail-react';

const relocItem: AzRailRelocItemProps = {
    id: "reloc-1",
    hostId: "host-1",
    text: "Item 1",
    isRailItem: false,
    isSubItem: true,
    forceHiddenMenuOpen: false,
    onHiddenMenuDismiss: () => { /* Menu dismissed */ },
    onRelocate: (fromIndex, toIndex, newOrder) => {
        // Handle new order
    },
    hiddenMenu: (scope: HiddenMenuScope) => {
        scope.listItem("Action 1", () => { /* ... */ });
        scope.inputItem("Rename", "Item 1", (newName) => { /* ... */ });
    }
};
// Pass this object within the items array to AzNavRail

System Overlay

AzNavRail can function as a system-wide overlay (using SYSTEM_ALERT_WINDOW), so the rail is reachable from anywhere on the device rather than only inside your app.

Supply the service class through azSettings(overlayService = MyOverlayService::class.java). Doing so implies enableRailDragging = true, and undocking the rail hands off to that service: the library requests SYSTEM_ALERT_WINDOW if it hasn’t been granted, then starts the service (as a foreground service when it extends AzNavRailOverlayService).

Your service extends one of:

Base class Permission Notes
AzNavRailOverlayService SYSTEM_ALERT_WINDOW + FOREGROUND_SERVICE Foreground service. Implement getNotification(); optionally override getNotificationId().
AzNavRailSimpleOverlayService SYSTEM_ALERT_WINDOW No notification, but the system may reclaim it sooner.

Declare the permissions and the service in your AndroidManifest.xml (FOREGROUND_SERVICE_SPECIAL_USE too when targeting API 34+), and call AzNavRail from inside the service’s content. onOverlayDrag reports (dx, dy) while the overlay is dragged; onRailDrag reports the same for in-app FAB dragging.

This is Android-only — on Desktop, Web and iOS, undocking stays an in-app floating rail.


Theming and Customization

The expanded menu text font size (and the footer items text size) is strictly controlled by your app’s MaterialTheme.typography.titleLarge. To adjust the text size inside the side menu drawer, simply customize the titleLarge attribute in your app’s typography theme!

Customizing Item Text and Colors

Navigation items support overriding their display text and colors when shown in the menu versus the rail using menuText, menuToggleOnText, menuToggleOffText, menuOptions, textColor, and fillColor properties! By default, the fillColor (translucent background) is automatically computed to be Black (with 25% opacity), unless the item’s main color is Black, in which case it is set to White (with 25% opacity) to ensure proper contrast.

One palette for every AzNavRail surface

activeColor is not just this rail’s accent — it is what every other AzNavRail surface draws itself in: a second unattached or floating rail, a drop-down menu, the About reader, the “More from Az” carousel, the Help overlay, nested-rail popups, popups, and the expanded drawer. Chrome that belongs to the same navigation system has to look like it; falling back to the app’s theme is how a rail’s own drop-down ends up a different colour from the rail.

Leave activeColor unset and the accent is derived from the rail’s own items — the colour most of them are drawn in — before it falls back to the app theme. A rail whose every button is white is a white rail, whatever MaterialTheme.colorScheme.primary says:

// No azTheme(activeColor = …) anywhere, yet the drop-down, the About reader and the unattached
// rail all come out white, because the rail reads as white.
azRailItem(id = "home", text = "Home", color = Color.White) { }
azRailItem(id = "docs", text = "Docs", color = Color.White) { }

Read it yourself when you need to match it:

val accent = LocalAzRailPalette.current.accent   // Color.Unspecified when no rail is present
const accent = useAzAccent()                     // falls back to the library default

The About reader and the drop-down’s panel also force their ground opaque: translucentBackground supplies the hue, never the alpha, because a see-through full-screen reader is an unreadable one — and a menu you can read the app’s artwork through is not a menu. Both then run a legibility guard over the accent: a colour the library chose is swapped for plain ink when it fails WCAG’s 3:1 large-text ratio against the panel, while a colour you named on an item is left exactly as declared.

React Implementation:

// Fonts and colors can be passed as props directly.
// The expanded menu text size can be handled via CSS or React Native styles
// depending on your implementation environment.
const settings: AzNavRailSettings = {
    activeColor: '#6200EE'
};

const items: AzNavItem[] = [
    {
        id: "custom",
        text: "Custom Color",
        color: '#FF0000',
        textColor: '#FFFFFF',
        fillColor: 'rgba(255,0,0,0.25)',
        // ...
    }
];

Status-Driven Guidance Framework

AzNavRail ships a reactive status-driven guidance framework (it replaces the old scripted scene/card tutorial framework). Instead of authoring a linear walkthrough, you describe your app’s userflow as a flowchart of statuses (string-id nodes) joined by edges (transitions that each carry an instruction). You declare goals (target statuses) and activate them; the engine then shows the instruction to reach the next status toward each active goal, auto-advancing the instant a target status becomes true (no Next button), re-routing live, and showing every active goal’s instruction at once as a callout next to its control.

Concepts

Built-in az.* statuses are auto-published: az.app.ready (always true), az.rail.expanded / az.rail.collapsed, az.rail.floating, az.host.<id>.expanded, az.screen.<route>, az.item.<id>.active, az.nestedRail.<id>.open, az.help.open, az.onscreen.<id>.visible.

Auto-edges are generated for rail affordances (e.g. az.rail.collapsed → az.rail.expanded → “Open the menu”, tap host → az.host.<id>.expanded, tap nested-rail → az.nestedRail.<id>.open, tap routed item → az.screen.<route>).

Predicate timing: predicates reading Compose/React state are observed instantly; predicates over non-reactive sources (a StateFlow.value, a plain var, a ref, an external store) are observed within a ~300 ms poll. Goal activation is developer-driven (no built-in end-user picker); autoStartWhen self-activates onboarding goals.

Android (Kotlin DSL)

The guidance DSL sits inside AzHostActivityLayout { ... }, which returns an AzGuidanceController:

import com.hereliesaz.aznavrail.tutorial.*

val controller = AzHostActivityLayout(navController = nav, currentDestination = route) {
    azRailHostItem(id = "features", text = "Features")
    azRailSubItem(id = "feature-a", hostId = "features", text = "Feature A", route = "feature-a")

    // Custom status + an edge INTO it (rail affordances are auto-edged):
    azStatus("profileComplete") { viewModel.profile.isComplete }
    azEdge(
        from = "az.screen.feature-a",
        to = "profileComplete",
        text = "Fill in your name and email to finish setup.",
        title = "Complete your profile",
        highlightItemId = "feature-a"
    )

    // Goals — autoStartWhen self-activates onboarding once:
    azGoal(id = "onboarding", target = "profileComplete", label = "Finish onboarding",
           autoStartWhen = { !guidance.isCompleted("onboarding") })
    azGoal(id = "find-features", target = "az.host.features.expanded", label = "Discover Features")
}

// Drive goals imperatively (also: LocalAzGuidanceController.current / rememberAzGuidanceController()):
controller.activate("find-features")
controller.deactivate("find-features")
controller.markReached("profileComplete")
val active: List<String> = controller.activeGoals
val done: List<String> = controller.completedGoals

AzGuidanceController (package com.hereliesaz.aznavrail.tutorial) exposes enabled, activeGoals, completedGoals, enable(), disable(), activate(id), deactivate(id), markReached(id), isCompleted(id). Completed goals persist in SharedPreferences under key az_navrail_completed_goals.

React Native / Web (TypeScript)

The DSL is expressed as JSX children of the rail; the controller is read with a hook:

import {
    AzGuidanceProvider, useAzGuidanceController,
    AzStatus, AzEdge, AzGoal,
} from '@HereLiesAz/aznavrail-react';

<AzNavRail /* … */>
    <AzRailHostItem id="features" text="Features" />
    <AzRailSubItem id="feature-a" hostId="features" text="Feature A" route="feature-a" />

    <AzStatus id="profileComplete" predicate={() => viewModel.profile.isComplete} />
    <AzEdge from="az.screen.feature-a" to="profileComplete"
            text="Fill in your name and email to finish setup."
            title="Complete your profile" highlightItemId="feature-a" />

    <AzGoal id="onboarding" target="profileComplete" label="Finish onboarding"
            autoStartWhen={() => !done.includes('onboarding')} />
    <AzGoal id="find-features" target="az.host.features.expanded" label="Discover Features" />
</AzNavRail>

// elsewhere:
const ctrl = useAzGuidanceController();
ctrl.activate('find-features');
const active: string[] = ctrl.activeGoals;
const done: string[] = ctrl.completedGoals;

The hook returns the same surface as Android (enabled, activeGoals, completedGoals, enable, disable, activate, deactivate, markReached, isCompleted). Also exported: AzGuidanceProvider, AzInstructionOverlay, useActiveStatuses, computeBuiltinStatuses, nextHop, routeInstructions, computeAutoEdges. Completed goals persist to localStorage (and AsyncStorage on RN) under az_navrail_completed_goals. (Parity note: the React/web overlay draws an accent ring around each target over a light dim, rather than a true punch-out spotlight.)

See docs/TUTORIAL_FRAMEWORK_PROPOSAL.md for the complete API reference and docs/AZNAVRAIL_COMPLETE_GUIDE.md for end-to-end usage examples.

Bottom Sheets

AzNavRail ships a four-detent bottom-sheet shell ported from LogKitty. One DSL line registers it inside AzHostActivityLayout; the sheet draws above the rail, above the menu, and above the onscreen area, spans the full screen width edge-to-edge, and extends all the way to the bottom of the screen so the HIDDEN-detent touch strip is reachable from the system-navigation-bar edge — a swipe-up from the gesture/nav-bar area reveals it. It is not a background().

val sheetController = rememberAzSheetController(initial = AzSheetDetent.PEEK)

AzHostActivityLayout(navController = nav, currentDestination = currentRoute) {
    azConfig(dockingSide = AzDockingSide.LEFT)
    azMenuItem(id = "home", text = "Home", route = "home", onClick = { /* … */ })
    onscreen { AzNavHost(startDestination = "home") { /* … */ } }

    azBottomSheet(controller = sheetController) {
        Column(Modifier.fillMaxSize().padding(16.dp)) {
            Text("Hello sheet")
            Button(onClick = { sheetController.stepUp() }) { Text("Expand") }
        }
    }
}

Detents — HIDDEN, PEEK, HALF, FULL — and the accumulated-delta drag gesture mirror LogKitty exactly. Dragging down steps the sheet down one detent at a time (FULL → HALF → PEEK → HIDDEN), mirroring the up-drag’s one-step expand. At HIDDEN, the strip stays visible with a dimmed drag-handle (28dp) and tapping the strip steps up to PEEK. At PEEK, a transparent tap overlay catches taps to step down to HIDDEN. At HALF/FULL, a dim scrim covers the background and tapping it steps down one detent. Inside AzBottomSheetWindowHost, the sheet content now receives the real window insets, so Modifier.navigationBarsPadding() (and WindowInsets.navigationBars) resolve correctly without the consumer measuring the nav bar; you can still pad inside your content lambda or use AzBottomSheetInsetAware. For Service-hosted overlays (LogKitty’s use case), use the AzBottomSheetWindowHost flavor — calling updateConfig(newConfig) resizes the live overlay window immediately at HIDDEN/PEEK (e.g. recomputing the nav-bar inset on rotation/split-screen). Both flavors share state, theming, and gesture handling so the visual is identical.

Navigation-bar awareness. Set AzSheetConfig.drawBehindNavBar = true to make the sheet draw behind the system navigation bar on button-navigation devices: the exposed height above the bar is unchanged, but the nav-bar background is forced see-through so the sheet content shows through it (in-tree this makes the host Activity’s nav bar transparent — below Android 15 only, since navigationBarColor is deprecated in API 35 and ignored for apps targeting SDK 35, where the bar is already transparent; in the overlay flavor it paints AzNavBarDecorWindow semi-transparent). It is a no-op in gesture navigation. Independently — and automatically, with no flag — the host imposes no bottom margin on on-screen content when the device uses gesture navigation (content runs edge-to-edge); button-navigation devices keep the usual safe-zone/inset bottom margin.

See docs/DSL.md, docs/API.md, and docs/AZNAVRAIL_COMPLETE_GUIDE.md for the full reference, and docs/MIGRATION_GUIDE.md for the LogKitty migration recipe.

Reorderable Items — persistence

azRailRelocItem items remember the order the user dragged them into for the lifetime of the host activity. The library stores per-host orderings in AzNavRailScopeImpl.savedRelocOrders and re-applies them after every recomposition (the DSL block is re-evaluated on each composition, so without this layer the original declaration order would always win). The onRelocate(from, to, newOrder) callback still fires on drop so you can persist the order externally (DataStore, room, etc.) and seed your DSL with the same arrangement on next launch.

Help Overlay — scoping & rendering

The interactive help overlay is scoped to where it was triggered from: tap azHelpRailItem in the main rail → cards for main-rail items; tap azHelpRailItem/azHelpSubItem inside an azNestedRail block → cards only for that nested rail’s children. Cards whose rail item is currently off-screen (scrolled out of the rail’s own scroll area, in a collapsed menu, or in a closed nested popup) are suppressed entirely — line, card, and all. The library tracks bounds via per-item DisposableEffect so the cache reflects “what’s currently on screen”, not “what was ever on screen”. Connector lines update in real-time as the user scrolls either the rail or the cards Column.

Sample app & PWA

The repo ships two showcase apps:

Documentation

The library includes a comprehensive Complete Guide (docs/AZNAVRAIL_COMPLETE_GUIDE.md) containing:

License

Copyright 2024 The AzNavRail Authors

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.