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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/app-launcher.jsonPrompt
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
gridcelllinks. 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
onErrorand re-checkscomplete && naturalWidth === 0in a ref callback, because on a prerendered page a cached image finishes before hydration attaches the handler and the event never comes.
Workspace Switcher
A workspace / organization switcher — active workspace in the trigger, grouped searchable menu in a portalled panel, pinned create + settings footer.
Nav Rail
A narrow vertical icon navigation bar that widens into a labelled column — keyboard-reachable tooltips, top/bottom zones, badges and a controllable expanded state.