Navigation

App Launcher

A nine-dot app switcher — a grid button that drops a portalled tile grid, with real links, grid keyboard navigation, groups, pinned and recent rows, and a search box once the suite outgrows a 3×3.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { ArrowUpRight, LayoutGrid, Search } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Entrance keyframes ship with the component: React 19 hoists <style href> into
 * <head> and dedupes by href, so several launchers on a page still emit one rule
 * set. One keyframe per side — the panel always slides *away* from its trigger.
 */
const KEYFRAMES = `@keyframes zg-app-launcher-in-bottom{from{opacity:0;transform:translateY(-4px) scale(0.98)}to{opacity:1;transform:none}}
@keyframes zg-app-launcher-in-top{from{opacity:0;transform:translateY(4px) scale(0.98)}to{opacity:1;transform:none}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/app-launcher.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "AppLauncher" component (lucide-react for
the glyphs; no popover / floating-ui dependency — the panel is hand-placed).

Contract
- export const AppLauncher = forwardRef<HTMLButtonElement, AppLauncherProps>.
  The ref, className and every remaining native button prop land on the trigger
  <button>, which is the component's only in-flow node (the panel is portalled).
  Also export the AppLauncherApp / AppLauncherGroup types.
- AppLauncherApp: { id: string (unique; referenced by pinnedIds / recentIds);
  name: string; href: string (a real destination — never "#"); icon?: ReactNode
  (rendered as-is: a lucide glyph, an emoji, an inline svg); iconUrl?: string
  (square logo, used only when `icon` is absent, falls back to initials when it
  fails); description?: string (second line, clamped to two lines, also
  searched); badge?: string ("New", "Beta", "3"); external?: boolean;
  disabled?: boolean; groupId?: string }.
- AppLauncherGroup: { id: string; label: string } — sections render in the order
  given; apps whose groupId matches nothing fall into a trailing section.
- AppLauncherProps: apps: AppLauncherApp[]; groups?; pinnedIds?: string[];
  recentIds?: string[]; columns? (default 3, clamped to 1-8); searchThreshold?
  (default 9 — a full 3x3 stays search-free; clamp to >= 0 so 0 means "always
  show", not "never"); searchPlaceholder?; emptyText?; label? (default "Apps",
  used as the accessible name of BOTH the trigger and the panel); align?:
  "start" | "end" (default "end" — launchers sit at the right end of a toolbar);
  open?/onOpenChange?; onLaunch?: (app) => void; pinnedLabel?; recentLabel?;
  otherLabel?; className? (trigger); panelClassName? (portalled panel).
- Open state is uncontrolled unless `open` is passed; onOpenChange fires in both
  modes. Navigation is NOT the component's job: tiles are real <a href> elements
  and onLaunch is only a notification that fires just before the panel closes.

Behavior
- Trigger: a square icon button (LayoutGrid) with aria-haspopup="dialog",
  aria-expanded, aria-controls and an aria-label. Click toggles, ArrowDown opens.
- Sections, in order: Pinned (from pinnedIds), Recent (from recentIds), one per
  group, then the leftovers. Unknown or repeated ids inside one section are
  dropped. Pinned/Recent DO repeat apps that also appear in their group — that is
  what those rows are for — so cell keys must be section-scoped, not app ids. The
  leftover section is labelled only when some other labelled section exists,
  otherwise a single-section panel would get a pointless heading.
- The search box appears only once apps.length is *greater than*
  searchThreshold. It filters case-insensitively over name + description + badge;
  empty sections disappear; nothing matching renders an emptyText paragraph with
  role="status".
- Markup: one role="grid" per section, labelled by the heading that sits OUTSIDE
  it via aria-labelledby (a heading inside the grid would be a node the grid is
  not allowed to own). Inside: role="row" > role="gridcell" > the <a>. Short rows
  are padded with empty gridcells so every row has the same column count.
- Keyboard: roving tabIndex — exactly one tile is a tab stop and the arrows move
  real DOM focus. Right/Left inside a row, Down/Up across rows keeping the column
  (clamped to that row's length), Home/End to the first/last app of the whole
  panel, Space activates (otherwise it would scroll the page behind this
  non-modal panel), Enter is the link's own. The arrows deliberately do NOT wrap:
  a launcher is a plane, and teleporting from the last tile back to the first is
  how people lose their place in it. From the search box, ArrowDown moves into
  the grid and Enter clicks the first enabled tile.
- Move focus synchronously inside the key handler, then store the key for the
  roving tabIndex. Do not focus from an effect keyed on the stored index:
  arrowing onto the value that is already stored is a no-op setState, React skips
  the render, the effect never runs and the focus ring simply stops moving.
  Derive the armed cell during render (stored key, falling back to the first
  cell) so filtering can never leave tabIndex pointing at a removed node.
- Disabled apps stay focusable and keep their slot in the arrow order; they only
  lose their href (nothing to follow, and no context menu offering a dead URL)
  and get role="link" + aria-disabled="true" — an <a> without href has no
  implicit role, which would leave the gridcell owning an anonymous box. Skipping
  them instead makes an app silently vanish for keyboard and screen reader users,
  who then never learn it exists but is unavailable to them.
- external apps get target="_blank" + rel="noopener noreferrer", a visible arrow
  glyph and an sr-only "(opens in a new tab)".
- Icons: caller node first, else the remote logo, else initials (first letter for
  one word, first + last for several, split by grapheme so an emoji or a CJK name
  is never cut in half). Handle remote failure in BOTH onError and a ref callback
  that tests `img.complete && img.naturalWidth === 0` — on a prerendered page a
  cached or data-URI image can finish decoding before hydration attaches onError,
  the event never arrives, and the broken icon sits there forever.
- Placement — the whole reason this is not one absolutely positioned div: render
  the panel into a portal on <body> with `position: fixed`. App bars, cards and
  docs preview stages set overflow:hidden constantly, and an in-flow panel gets
  clipped: tiles exist in the DOM, pixels don't, nothing is clickable. Then clamp
  against `viewport ∩ every ancestor whose computed overflow is auto|scroll`. Do
  NOT treat overflow:hidden ancestors as a boundary: the portal already escaped
  them, and clamping into a 200px decorative header would crush the panel instead
  of freeing it.
- The column count comes from that boundary, never from the panel's own measured
  width: columns = clamp(floor((room - padding) / (tile + gap)), 1, columns). The
  panel's width is a function of the columns, so measuring the width to pick the
  columns would feed the result straight back into its own input. Measured on the
  shipped build: columns={4} becomes 3 columns / a 312px panel at a 390px
  viewport, and 2 columns / a 212px panel inside a 288px scrollable rail; neither
  overflows.
- The *height* is still measured, with your own maxHeight momentarily set to
  "none" (a panel already capped by the previous pass always looks like it fits,
  which is how hand-rolled popovers end up oscillating), then restore the cap and
  the tile area's scrollTop. Flip to the top only when the panel does not fit
  below and above is roomier, cap maxHeight to the space that exists, align to
  the trigger edge, then shift back inside the boundary on BOTH axes. The main
  axis needs the shift as much as the cross axis: the panel is `position: fixed`,
  so anything that hangs past the boundary can never be scrolled to. Measured on
  the shipped build with only a main-axis cap and no main-axis shift: a trigger
  15px above the boundary's bottom edge left 167 of the panel's 176px off-screen,
  `elementFromPoint` on its centre returned null and none of the six tiles could
  be hit. ~176px is the *preferred* height — a short side keeps it and scrolls —
  but it yields to a boundary too small to hold it, because a squeezed panel is
  usable and an escaped one is not. When the strip left over is shorter than one
  tile, centre the focused tile in the scroller instead of aligning its bottom
  edge, or its middle (the part the pointer lands on) stays out of view.
- Run that pass inside a ResizeObserver callback on the panel and the trigger:
  observe() fires once immediately, after layout and before paint, so it doubles
  as the initial measurement and nothing setStates synchronously in an effect
  body. Also re-run on window resize and on capture-phase scroll (passive, rAF
  throttled, skipping scrolls that originate inside the panel — that is what
  keeps the panel glued to a trigger inside a scrolling rail), and re-arm it when
  filtering changes the row count, since a natural height that changes under an
  unchanged clamped box never fires the observer. Guard the setState with a
  layout-equality check or the observer loops on your own caps.
- Until the first measurement lands the panel has no coordinates: hide that frame
  with opacity-0, never visibility:hidden — the latter also makes the subtree
  unfocusable, so the focus() that follows fails silently and every keyboard path
  (Escape included) dies with it.
- NO scroll lock. This panel is non-modal: it hangs off a toolbar button, the page
  behind stays readable and interactive, and the panel re-measures on scroll
  instead of freezing the document. It therefore also never claims aria-modal and
  never traps Tab. A launcher that must block the page is a dialog, not this.
- Dismissal: Escape (handled on the panel's own onKeyDown with stopPropagation,
  never a window listener, so a launcher opened inside another overlay closes
  only itself), outside pointerdown, focus moving outside, launching an app, and
  a second trigger click. Everything except the outside interactions restores
  focus to the trigger — and only after checking `el.isConnected`, because
  launching an app routinely unmounts it and focusing a detached node silently
  drops focus onto <body> instead of throwing.
- Tab discipline, since the panel is portalled to the end of <body>: stepping off
  the last tab stop (or Shift+Tab off the first) closes the panel and returns
  focus to the trigger, so one more Tab continues through the toolbar in the
  order the user expects. Compute those stops with a `tabIndex >= 0` filter —
  every tile is an <a href>, so a plain focusable-selector match reports nine
  stops in a 3x3 grid and the edge test never fires.

Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground for the panel,
  border, bg-accent + text-accent-foreground for hover, bg-primary/10 +
  text-primary for the icon square, text-muted-foreground for descriptions and
  section headings, ring-ring for focus. No hex, no rgb(), no palette class
  names. cn() merges the consumer's className.
- Tiles: fixed-width columns (~96px) laid out as flex rows, icon square on top,
  name truncated to one line, optional two-line clamped description, badge pinned
  to the tile's top-right corner.
- Entrance keyframes ship inside the component through React 19's <style href>
  hoisting (deduped by href, so ten launchers still emit one rule set), one per
  side so the panel always slides away from its trigger, and are disabled under
  motion-reduce — the panel still opens, it just appears.
- Accessibility: the icon square is aria-hidden (the name sits right below it and
  would only be duplicated), focus-visible:ring-2 ring-ring ring-inset on tiles,
  aria-label on the trigger, role="dialog" + aria-label on the panel.

Customization levers
- Density: TILE_WIDTH / TILE_GAP / PANEL_PADDING are three constants that drive
  both the CSS and the column maths — change them together and the panel width,
  the point at which columns drop, and the layout all stay consistent.
- Shape: `columns` (2 for a compact rail, 4-5 for a wide suite), `align`,
  MIN_PANEL_HEIGHT, and the icon square's size / radius / token.
- Content: drop `description` for a denser icons-and-names grid, drop `badge`, or
  add a pinned footer row ("More apps", "App settings") outside the scroll area —
  outside is what keeps it reachable after a query that matches nothing.
- Search: `searchThreshold` (0 = always on), and the fields matchesQuery looks at
  (add a `keywords` array if slugs and aliases should be searchable without being
  rendered).
- Sections: pinnedIds / recentIds are plain id lists — feed them from
  localStorage or usage telemetry. Removing `groups` collapses everything into
  one unlabelled grid.
- Motion: the two keyframes are the only animation; swap them for a
  scale-from-the-trigger-corner effect, or delete them for an instant panel.

Concepts

  • Boundary-derived columns — the column count is computed from the room the boundary leaves, never from the panel's own width; the width depends on the columns, so measuring it to choose them would be a feedback loop. Measured on this build: columns={4} becomes 3 columns (312px panel) at a 390px viewport and 2 columns (212px panel) inside a 288px scrollable rail.
  • Roving tabindex over an ARIA grid — the whole panel is one tab stop and the arrows move real DOM focus between gridcell links. Focus moves synchronously in the key handler: driving it from an effect keyed on the stored index stalls the moment you arrow onto the index already stored, because React skips that no-op render.
  • Focusable disabled tile — a disabled app keeps its place in the arrow order and stays announced; it only loses its href. Removing it from the order would make the app silently disappear for exactly the users who most need to be told it exists but is unavailable.
  • Non-modal by construction — no scroll lock, no aria-modal, no focus trap. The page behind stays live and the panel re-measures on capture-phase scroll so it keeps tracking its trigger; the only Tab special case is closing when focus steps off either edge of the portalled panel.
  • Settled-image probe — the remote logo listens for onError and re-checks complete && naturalWidth === 0 in a ref callback, because on a prerendered page a cached image finishes before hydration attaches the handler and the event never comes.

On This Page