Account menu
NewAn avatar that opens an account menu with status, number-key hints and two submenus that open to the left.
account-menu/account-menu.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- The signed-in person’s own corner of an app: their profile, settings, appearance and logging out, behind their avatar at the end of the header.
- Account-level choices with a few values each, such as a presence status or the workspace in use, kept one step away in a submenu.
- Frequent entries that deserve a key: number keys and a letter choose them without leaving the keyboard.
- Apps with several workspaces or teams, where switching space belongs with the account rather than in the navigation.
- Menus long enough that their submenus are reached by crossing other entries, where a safe triangle keeps the pointer’s path forgiving.
When not to use
- The app’s main navigation between pages. Use a sidebar or tabs, which stay on screen.
- Actions on a selected item, such as a row or a file. Use a context menu or a row’s own overflow menu.
- Signing in, for someone who is not signed in yet. Use a plain Sign in button that opens the login.
- A full settings screen with forms. Use a settings page, and link to it from here.
- A choice between many spaces that needs search. Use a command palette or a space picker dialog.
- Touch-only screens where hover intent means nothing. Use a sheet that slides up, with its submenus as pages.
You can add this component with the shadcn CLI or by hand:
Into components/account-menu of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/account-menu.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { AccountMenu } from './account-menu';export function Header() { return ( <AccountMenu name="Nora Lindell" email="nora@planko.app" defaultOpen={false} autoplay={false} onSelect={(id) => console.log(id)} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
The avatar at the end of an app’s header opens the account’s menu: who is signed in with a status chip, Update status, four entries with number keys, an offer, links out, and on a grey ledge underneath, Switch space and Log out. Update status and Switch space open submenus to the left on hover or with the arrow keys, kept open by a safe triangle while the pointer crosses other entries. Appearance flips the menu between light and dark.
- It is a WAI-ARIA menu button: the avatar has aria-haspopup and aria-expanded, the menu and its submenus have role menu, groups and separators, and the entries are menu items (radios in the submenus). One entry at a time is highlighted, and when the reader drives it the highlight is the focus too (a roving focus).
- The pointer highlights the entry under it. Resting 110 ms on Update status or Switch space opens its submenu (hover intent); a click opens it at once.
- Leaving the entry for its submenu, the pointer may cross other entries. While it stays in the safe triangle, from where it left the entry to the submenu’s near and far corners, nothing else highlights; if it stops in there for 320 ms, the entry under it wins and the submenu closes.
- Submenus open to the left of the menu, placed by the entry that opens them: Update status beside its first status, Switch space with Add new space level with it. Where the stage is too narrow for both side by side, the submenu overlaps the menu instead.
- The menus stay in the page, inert while closed. They open in 260 ms on an ease-out curve, scaling from 0.96 out of the avatar’s corner, and close faster, in 180 ms; submenus slide in 8 px from the menu’s side.
- Keys: Enter, Space or ↓ on the avatar open the menu on its first entry and ↑ on its last; ↑ ↓ Home End move, letters jump to an entry, 1–4 and S choose theirs, → or ← opens a submenu and closes it again, ⌘⌃1–3 and ⌘⌃P switch space or add one, Escape closes and Tab leaves.
- The demo pointer runs through the same hover code as a real pointer: every frame it reads the entry under the arrow’s tip, so the hover intent and the safe triangle work for it too.
- Every colour is a light-dark() token on the root, so the menu follows the page’s color-scheme or the colorScheme prop, and Appearance sets data-color-scheme to the other scheme.
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | 'Nora Lindell' | Who is signed in. |
email | string | 'nora@planko.app' | Their email, under the name. |
spaces | AccountSpace[] | SPACES | The spaces Switch space offers, each { id, name, tile } with tile a CSS background; the first three get ⌘⌃1–3. |
defaultSpace | string | — | The id of the space in use at first; the first by default. |
defaultStatus | 'online' | 'away' | 'busy' | 'offline' | 'offline' | The status at first, shown on the chip. |
offer | string | '20% off' | The badge on Upgrade to Premium; an empty string hides it. |
defaultOpen | boolean | true | The menu is open at first. |
defaultSubmenu | 'status' | 'space' | — | A submenu open at first, with the menu. |
onSelect | (id: string) => void | — | Called with an entry’s id when it is chosen: profile, settings, notifications, premium, referrals, download, news, help, switch-account, logout or add-space. |
onStatusChange | (status: AccountStatus) => void | — | Called when a status is chosen. |
onSpaceChange | (id: string) => void | — | Called with a space’s id when it is switched to. |
autoplay | boolean | true | While on screen and untouched, a demo pointer opens the menu and walks through it; any input hands it to the reader. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme; Appearance flips it. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { Fragment, useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode,} from 'react';import { DemoPointer, runDemo, type Point } from './demo-pointer';import * as Icon from './icons';import { useAutoplay } from './use-autoplay';import styles from './account-menu.module.css';/* * Account menu: the avatar at the end of an app's header opens the account's menu. A white card/* Account menu (account-menu.tsx): an app's header and, faintly behind, its tabs and cards; the avatar opens the menu below it, and submenus open to the menu's left. Sizes are px at the drawing's own scale; the menu is 282 px wide. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), the one its colorScheme prop sets, or the one the Appearance entry flips to (data-color-scheme). Memory. The stage is one layout boundary of a fixed height, so nothing inside it lays out the page. The menus are always in the page, inert and hidden while closed; they open and close by opacity and transform, and the demo pointer moves by transforms. */.root { /* The app's ground, the menu's white card and the grey ledge under it. */ --page: #fff; --surface: #fff; --ledge: #f9f9f9; /* Words: the entries, the quieter lines and icons, the faintest icons. */ --ink: #0a0b0c; --ink-2: #6c6e70; --ink-3: #a3a3a3; /* Lines between the groups, the menus' outline, and their lift. */ --line: #f6f6f6; --edge: rgb(0 0 0 / 0.055);// Copyright (c) 2026 Bruno Delorence. MIT License./* * The account menu's icons: line drawings on a 24-unit grid, stroked in the colour of the text * around them (currentColor). Their size comes from the stylesheet. All are decorative: the * words next to them say what they mean. */import type { ReactNode, SVGProps } from 'react';function Svg({ children, ...props }: SVGProps<SVGSVGElement> & { children: ReactNode }) { return ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false" {...props} > {children}// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it. The arrow and the * hand are HTML boxes that swap by their visibility: a change of style inside an SVG would lay the * SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver(- The avatar is a menu button named “Account menu” and the user’s name, with aria-haspopup, aria-expanded and aria-controls. The menu is named with the name, email and status; its header is hidden from screen readers, which hear that name instead.
- Entries are menu items in groups with separators; Update status and Switch space say they open a menu and whether it is expanded, statuses and spaces are menu item radios with aria-checked, and hotkeys are given by aria-keyshortcuts (the key caps themselves are hidden).
- Focus is roving: arrow keys, Home, End and typeahead move it, Escape closes a submenu back to its entry or the menu back to the avatar, and Tab closes the menu. Closed menus are inert, so they are never reached.
- A polite live region says what happened: a status set, a space switched, an entry chosen, the appearance changed.
- The demo pointer is hidden from screen readers and never moves focus. Under reduced motion the menus only fade and the demo never plays.