Skip to content
Components

Mobile navigation

A floating navigation pill and an expanding stack of quick actions.

Open preview ↗

Interact with the example above, or open it in a full window.

After setting up the library, copy the example files into your app and adapt the content. These are composition references, with no block API to install or import.

Load the copied stylesheet from your application layout. Place the controller in your Stimulus controller directory and register it using your app's usual loading convention.

Choose a small set of primary destinations and replace the sample quick actions.

Copy the bottom spacing and safe-area rules with the controls, so page content remains reachable. Test keyboard visibility on a real phone.

The preview renders this ERB and loads these styles and interactions.

app/views/shared/_mobile_navigation.html.erb

<div class="mobile-example min-h-screen" data-controller="blocks-mobile-navigation" data-action="resize@window->blocks-mobile-navigation#keyboard focusin@document->blocks-mobile-navigation#keyboard focusout@document->blocks-mobile-navigation#keyboard">
  <header class="mx-auto flex max-w-2xl items-center justify-between gap-4 px-5 pt-7 pb-6 sm:px-8">
    <a href="#projects" class="flex min-h-11 items-center gap-2 font-semibold" data-action="blocks-mobile-navigation#home"><%= lc_icon "square-3-stack-3d", options: { class: "size-5", "aria-hidden": true } %> Studio</a>
    <%= lc_avatar "AL", class: "bg-primary-soft text-primary", aria: { label: "Alex Lee" } %>
  </header>

  <nav class="mobile-example__tabs" aria-label="Studio sections">
    <div class="<%= lc_glass_class(thick: true, extra: "relative isolate grid grid-flow-col auto-cols-fr gap-1 p-1") %>">
      <span class="mobile-example__indicator" data-blocks-mobile-navigation-target="indicator" aria-hidden="true"></span>
      <% [ [ "Projects", "projects", "rectangle-stack" ], [ "Notes", "notes", "document-text" ], [ "People", "people", "users" ] ].each do |label, id, icon| %>
        <a href="#<%= id %>" class="mobile-example__tab" <%= 'aria-current=page' if id == "projects" %> data-blocks-mobile-navigation-target="link" data-action="blocks-mobile-navigation#select">
          <%= lc_icon icon, options: { class: "size-5", "aria-hidden": true } %><span><%= label %></span>
        </a>
      <% end %>
    </div>
  </nav>

  <main class="mobile-example__main isolate mx-auto flex max-w-2xl flex-col gap-7 px-5 pt-3 sm:px-8">
    <div class="flex flex-col gap-2"><%= lc_text "A space to make things", size: :caption, tone: :muted %><h1 class="text-4xl font-semibold tracking-tight">Your little studio.</h1><p class="text-muted-foreground">A few good ideas, and room for a few more.</p></div>
    <p class="text-sm text-muted-foreground" role="status" aria-live="polite" data-blocks-mobile-navigation-target="status"></p>

    <section id="projects" class="flex flex-col gap-4" data-blocks-mobile-navigation-target="panel">
      <div class="flex min-h-11 items-center justify-between gap-3"><h2 class="text-xl font-semibold">On your desk</h2><%= lc_badge "This week" %></div>
      <div class="flex flex-col gap-4" data-blocks-mobile-navigation-target="projects">
        <% [ [ "Brand refresh", "Finding a fresh perspective.", "swatch", :primary, "In progress" ], [ "Field notes", "Small observations, collected.", "book-open", :success, "Taking shape" ], [ "Summer collection", "Objects for everyday moments.", "sun", :neutral, "Just getting started" ] ].each do |title, description, icon, tone, status| %>
          <%= lc_card class: "flex flex-col gap-content" do %>
            <div class="flex items-center justify-between gap-3"><%= lc_icon_badge shape: :circle, icon: icon, size: :lg, tone: tone%><%= lc_badge status %></div>
            <div class="flex flex-col gap-1"><h3 class="text-lg font-semibold"><%= title %></h3><p class="text-sm text-muted-foreground"><%= description %></p></div>
            <div class="flex items-center gap-2 text-sm text-muted-foreground"><%= lc_avatar "AL", size: :sm, class: "bg-background text-muted-foreground" %><span>You and your collaborators</span></div>
          <% end %>
        <% end %>
      </div>
    </section>

    <section id="notes" class="flex flex-col gap-4" data-blocks-mobile-navigation-target="panel" hidden>
      <h2 class="text-xl font-semibold">Notes to self</h2>
      <div class="flex flex-col gap-4" data-blocks-mobile-navigation-target="notes">
        <%= lc_card class: "flex flex-col gap-3" do %><%= lc_text "A thought for today", size: :caption, tone: :muted %><h3 class="text-lg font-semibold">Leave a little room.</h3><p class="text-sm text-muted-foreground">The best ideas sometimes arrive between the things you planned.</p><% end %>
      </div>
      <%= lc_field label: "Capture a thought", id: "studio-thought", hint: "A small idea is a good place to start." do |field| %><%= lc_textarea name: "thought", placeholder: "What’s on your mind?", rows: 4, **field %><% end %>
    </section>

    <section id="people" class="flex flex-col gap-4" data-blocks-mobile-navigation-target="panel" hidden>
      <h2 class="text-xl font-semibold">Good company</h2>
      <%= lc_card class: "flex flex-col gap-content" do %>
        <% [ [ "AL", "Alex Lee", "Designer" ], [ "SK", "Sam Kim", "Writer" ], [ "JR", "Jamie Reed", "Maker" ] ].each do |initials, name, role| %>
          <div class="flex items-center gap-3"><%= lc_avatar initials, class: "bg-background text-muted-foreground" %><div class="flex-1"><h3 class="text-sm font-semibold"><%= name %></h3><p class="text-sm text-muted-foreground"><%= role %></p></div><%= lc_badge "Available", tone: :primary, class: "rounded-full text-sm" %></div>
        <% end %>
      <% end %>
    </section>
  </main>

  <div class="mobile-example__dock" data-controller="lc-menu" data-lc-menu-lift-value="false">
    <button type="button" class="mobile-example__trigger <%= lc_glass_class(shape: :disc, thick: true) %>" aria-label="Create something" aria-haspopup="menu" aria-expanded="false" aria-controls="studio-create-menu" data-lc-menu-target="trigger" data-action="lc-menu#toggle">
      <%= lc_icon "plus", options: { class: "size-7", "aria-hidden": true } %>
    </button>
    <div id="studio-create-menu" class="mobile-example__actions" role="menu" aria-label="Create something" data-lc-menu-target="popover">
      <button type="button" class="mobile-example__scrim" aria-label="Close create menu" tabindex="-1" data-action="lc-menu#close"></button>
      <% [ [ "New project", "rectangle-stack", "projects" ], [ "Add a note", "pencil-square", "notes" ] ].each_with_index do |(label, icon, destination), index| %>
        <button type="button" role="menuitem" class="mobile-example__action <%= lc_glass_class(shape: :disc, thick: true) %>" style="--action-index: <%= index + 1 %>" data-action="blocks-mobile-navigation#add lc-menu#close" data-blocks-mobile-navigation-destination-param="<%= destination %>">
          <span class="mobile-example__action-label"><%= label %></span><%= lc_icon icon, options: { class: "size-6", "aria-hidden": true } %>
        </button>
      <% end %>
    </div>
  </div>

  <template data-blocks-mobile-navigation-target="draft">
    <%= lc_card class: "flex flex-col gap-3" do %><%= lc_badge "Draft" %><h3 class="text-lg font-semibold" data-draft-title></h3><p class="text-sm text-muted-foreground">A fresh start. Make it your own.</p><% end %>
  </template>
</div>

app/assets/stylesheets/mobile_navigation.css

.mobile-example {
  --dock-gap: max(1rem, env(safe-area-inset-right, 0px));
  --dock-bottom: max(0.75rem, env(safe-area-inset-bottom, 0px));
  --dock-size: 4rem;
}

.mobile-example__main {
  padding-block-end: calc(var(--dock-bottom) + var(--dock-size) + 2rem);
}

.mobile-example__tabs {
  position: fixed;
  z-index: var(--lc-z-sticky);
  inset-inline: max(1rem, env(safe-area-inset-left, 0px)) calc(var(--dock-gap) + var(--dock-size) + 0.75rem);
  bottom: var(--dock-bottom);
}

.mobile-example__tab {
  position: relative;
  display: flex;
  min-width: 0;
  min-height: 3.5rem;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.25rem;
  border-radius: 999px;
  color: var(--muted-foreground);
  font-size: 0.6875rem;
  font-weight: 600;
  text-decoration: none;
}

.mobile-example__tab[aria-current="page"] {
  color: var(--primary);
}

.mobile-example__indicator {
  position: absolute;
  z-index: -1;
  inset-block: 0.25rem;
  inset-inline-start: 0;
  border-radius: 999px;
  background: color-mix(in oklab, var(--foreground) 7%, transparent);
  transition: translate 380ms var(--lc-ease-sheet, ease), width 380ms var(--lc-ease-sheet, ease);
}

.mobile-example__dock {
  position: fixed;
  z-index: calc(var(--lc-z-sticky) + 1);
  inset-inline-end: var(--dock-gap);
  bottom: var(--dock-bottom);
}

.mobile-example__trigger,
.mobile-example__action {
  display: grid;
  width: var(--dock-size);
  height: var(--dock-size);
  place-items: center;
  padding: 0;
  color: var(--primary);
  cursor: pointer;
}

.mobile-example__trigger {
  position: relative;
  z-index: 2;
}

.mobile-example__trigger svg {
  transition: rotate 300ms var(--lc-ease-sheet, ease);
}

.mobile-example__trigger[aria-expanded="true"] svg {
  rotate: 135deg;
}

.mobile-example__actions {
  visibility: hidden;
  pointer-events: none;
}

.mobile-example__actions[data-open] {
  visibility: visible;
}

.mobile-example__scrim {
  position: fixed;
  inset: 0;
  border: 0;
  background: color-mix(in oklab, var(--foreground) 18%, transparent);
  opacity: 0;
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  transition: opacity 220ms ease;
}

.mobile-example__actions[data-open] .mobile-example__scrim {
  pointer-events: auto;
  opacity: 1;
}

.mobile-example__action {
  position: absolute;
  z-index: 1;
  inset: 0;
  width: 3.5rem;
  height: 3.5rem;
  margin: 0.25rem;
  color: var(--foreground);
  opacity: 0;
  transform: scale(0.4);
  transition: transform 300ms ease, opacity 150ms ease;
}

.mobile-example__actions[data-open] .mobile-example__action {
  pointer-events: auto;
  opacity: 1;
  transform: translateY(calc(var(--action-index) * -4.5rem)) scale(1);
  transition: transform 420ms cubic-bezier(0.34, 1.45, 0.64, 1), opacity 160ms ease;
  transition-delay: calc((var(--action-index) - 1) * 40ms);
}

.mobile-example__action-label {
  position: absolute;
  inset-inline-end: calc(100% + 0.75rem);
  width: max-content;
  max-width: calc(100vw - 7rem);
  padding: 0.5rem 0.875rem;
  border-radius: 999px;
  background: var(--background);
  box-shadow: var(--lc-shadow);
  color: var(--foreground);
  font-size: 0.875rem;
  font-weight: 600;
}

.mobile-example[data-keyboard] .mobile-example__tabs,
.mobile-example[data-keyboard] .mobile-example__dock {
  display: none;
}

@media (width >= 40rem) {
  .mobile-example__tabs {
    position: relative;
    inset: auto;
    width: max-content;
    margin-inline: auto;
    margin-block-end: 1.5rem;
  }

  .mobile-example__tab {
    min-width: 7rem;
    min-height: 2.75rem;
    flex-direction: row;
    gap: 0.5rem;
    font-size: 0.875rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .mobile-example__indicator,
  .mobile-example__trigger svg,
  .mobile-example__scrim,
  .mobile-example__action,
  .mobile-example__actions[data-open] .mobile-example__action {
    transition: none;
  }
}

app/javascript/controllers/blocks_mobile_navigation_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["link", "panel", "indicator", "status", "projects", "notes", "draft"]

  connect() {
    this.resizeObserver = new ResizeObserver(() => this.placeIndicator())
    this.linkTargets.forEach((link) => this.resizeObserver.observe(link))
    this.onViewportResize = () => this.keyboard()
    window.visualViewport?.addEventListener("resize", this.onViewportResize)
    this.placeIndicator()
  }

  disconnect() {
    this.resizeObserver.disconnect()
    window.visualViewport?.removeEventListener("resize", this.onViewportResize)
  }

  select(event) {
    event.preventDefault()
    this.show(event.currentTarget.hash.slice(1))
  }

  home(event) {
    event.preventDefault()
    this.show("projects")
  }

  show(id) {
    for (const link of this.linkTargets) {
      if (link.hash === `#${id}`) link.setAttribute("aria-current", "page")
      else link.removeAttribute("aria-current")
    }
    for (const panel of this.panelTargets) panel.hidden = panel.id !== id
    this.placeIndicator()
  }

  placeIndicator() {
    const active = this.linkTargets.find((link) => link.getAttribute("aria-current") === "page")
    this.indicatorTarget.style.translate = `${active.offsetLeft}px 0`
    this.indicatorTarget.style.width = `${active.offsetWidth}px`
  }

  add({ params: { destination } }) {
    const draft = this.draftTarget.content.cloneNode(true)
    const title = destination === "projects" ? "Untitled project" : "A new thought"
    draft.querySelector("[data-draft-title]").textContent = title
    const list = destination === "projects" ? this.projectsTarget : this.notesTarget
    list.prepend(draft)
    this.show(destination)
    this.statusTarget.textContent = `${title} added. Your next idea starts here.`
  }

  keyboard() {
    const typing = document.activeElement?.matches("input, textarea, [contenteditable=true]")
    const inset = window.innerHeight - (window.visualViewport?.height ?? window.innerHeight)
    this.element.toggleAttribute("data-keyboard", typing && inset > 150)
  }
}
← All blocks