CollaborationProfile menu
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callProfile menu
The account menu behind an avatar: switch workspace, theme, shortcuts and sign out. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
- ↑↓
- Move through the menu
- HomeEnd
- First or last item
- Esc
- Close the menu
- ?
- Show these shortcuts
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add profile-menuReact
npx designme add profile-menu --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Profile menu is the account menu that opens from an avatar in an app's top bar. It shows who is signed in, lists the workspaces to switch between, and holds a theme switch, a Keyboard shortcuts row with its key, and Sign out. It is a real menu: the arrow keys move through it, Escape closes it and puts the focus back on the avatar.
- When people belong to more than one workspace and switch between them from anywhere in the app.
- When the account's settings that apply everywhere, like the theme, should sit one click from every page.
- When the app has keyboard shortcuts and people need a place to find them.
- When the top bar has room for an avatar but not for a row of account buttons.
- The menu opens under the avatar, lined up with its right edge, and starts with the person's name and email.
- The workspaces are a group of radio items, each with a tinted tile, a name and a quiet
detaillike "Free, 3 members". The current one is checked; picking another closes the menu and the bar crossfades to its name. - The theme is a switch row. It stays in the menu after a click, so the change shows where it was made. It reports the change through
onDarkChange; applying the theme is up to you. - Keyboard shortcuts shows its key, ?, and opens a short list under the bar. Pressing ? anywhere in the component opens and closes the same list. By default it lists the keys the menu itself answers to.
- Sign out swaps the avatar for a Sign in button, which swaps it back.
noteshows a confirmation with a green check under the bar, like "Switched to Acme Labs".
- The avatar is a button with
aria-haspopup="menu"andaria-expanded, labelled "Account menu" and the person's name. - The menu has
role="menu"and is named by the name and email at its top. The workspaces aremenuitemradioitems in a group named "Workspaces"; the theme row is amenuitemcheckbox; the rest aremenuitems. - Enter, Space or the down arrow on the avatar opens the menu on its first item, and the up arrow on its last. Inside, the arrow keys move and wrap, Home and End jump to the ends, Escape closes it and gives the focus back to the avatar, and Tab closes it and moves on.
- The confirmation under the bar is a polite live region, so a switch or a theme change is announced.
Usage
Import it and drive it from your own state.
Import
import { ProfileMenu } from '@/components/designme/profile-menu/ProfileMenu';Example
The demo above as a React component, 41 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { ProfileMenu, type ProfileMenuWorkspace } from './ProfileMenu';
import { useStory } from '../lib/useScript';
/* ---------- Profile menu: open it, switch to Acme Labs, then open it again and flip the theme ---------- */
const WORKSPACES: ProfileMenuWorkspace[] = [
{ id: 'northwind', name: 'Northwind', detail: 'Pro, 12 members', tint: 'pb' },
{ id: 'acme', name: 'Acme Labs', detail: 'Free, 3 members', tint: 'pp' },
{ id: 'personal', name: 'Personal', detail: 'Just you', tint: 'pl' }
];
export default function ProfileMenuDemo() {
const ref = useRef<HTMLDivElement>(null);
const [workspace, setWorkspace] = useState('northwind');
// the switch starts from the page's theme; apply it to your own app in onDarkChange
const [dark, setDark] = useState(() => typeof document !== 'undefined' && (document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches));
const [signedIn, setSignedIn] = useState(true);
const [note, setNote] = useState<string>();
// the preview's story: open the menu, switch to Acme Labs, then open it again and flip the theme
useStory(ref, async ({ wait, press }) => {
await wait(1500); press('.dm-pfm-tr');
await wait(3000); press('.dm-mi[role="menuitemradio"]:nth-of-type(2)');
await wait(2500); press('.dm-pfm-tr');
await wait(1500); press('.dm-pfm-th');
await wait(1800); press('.dm-pfm-tr');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<ProfileMenu user={{ name: 'Maya Kim', email: '[email protected]', color: 'var(--dm-av-l)' }} workspaces={WORKSPACES} context="Pricing refresh"
workspace={workspace} onWorkspaceChange={id => { if (id !== workspace) setNote(`Switched to ${WORKSPACES.find(w => w.id === id)!.name}`); setWorkspace(id); }}
dark={dark} onDarkChange={d => { setDark(d); setNote(`Theme set to ${d ? 'dark' : 'light'}`); }}
signedIn={signedIn} onSignedInChange={s => { setSignedIn(s); setNote(s ? 'Signed in as Maya Kim' : `Signed out of ${WORKSPACES.find(w => w.id === workspace)!.name}`); }}
note={note} />
</div>
);
}
Props
Everything ProfileMenu takes.
| Prop | Type | What it does |
|---|---|---|
userrequired | { name: string; email: string; initials?: string; color?: string } | The person signed in. `color` is the avatar's colour, like 'var(--dm-av-l)' |
workspacesrequired | ProfileMenuWorkspace[] | |
workspace | string | The current workspace's id. Leave it out to let the menu switch it |
defaultWorkspace | string | |
onWorkspaceChange | (id: string) => void | |
context | ReactNode | The quiet line under the workspace name in the bar, like the page you're on |
dark | boolean | The theme switch. It only reports the change: apply the theme in `onDarkChange` |
defaultDark | boolean | |
onDarkChange | (dark: boolean) => void | |
themeLabel | string | Defaults to "Dark theme" |
shortcuts | ProfileMenuShortcut[] | The list Keyboard shortcuts opens. Defaults to the keys this menu answers to |
shortcutsOpen | boolean | Whether that list is open. Keyboard shortcuts, or ? anywhere in the component, opens it |
defaultShortcutsOpen | boolean | |
onShortcutsOpenChange | (open: boolean) => void | |
signedIn | boolean | Sign out swaps the avatar for a Sign in button, which swaps it back |
defaultSignedIn | boolean | |
onSignedInChange | (signedIn: boolean) => void | |
note | string | A confirmation under the bar, like "Switched to Acme Labs". Shown with a green check |
open | boolean | Whether the menu is open. Leave it out to let the avatar, Escape and a click outside open and close it |
defaultOpen | boolean | |
onOpenChange | (open: boolean) => void | |
className | string |
Code
The full source, as the CLI installs it.
ProfileMenu.tsxReact · 185 lines
'use client';
import { useEffect, useId, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { avatarInk, enter, Fold, Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './profile-menu.css';
/** The tint of a workspace's tile, from the designme palette */
export type ProfileMenuTint = 'pb' | 'pl' | 'pm' | 'pp' | 'pr' | 'py' | 'pk' | 'pt';
/** One workspace the account belongs to */
export interface ProfileMenuWorkspace {
id: string;
/** Like "Acme Labs" */
name: string;
/** The quiet line under the name in the menu, like "Free, 3 members" */
detail?: string;
/** The letter in its tile. Defaults to the first letter of the name */
initial?: string;
/** The tile's tint. Grey when left out */
tint?: ProfileMenuTint;
}
/** A row in the shortcuts list: the keys, then what they do */
export interface ProfileMenuShortcut {
keys: string[];
label: string;
}
export interface ProfileMenuProps {
/** The person signed in. `color` is the avatar's colour, like 'var(--dm-av-l)' */
user: { name: string; email: string; initials?: string; color?: string };
workspaces: ProfileMenuWorkspace[];
/** The current workspace's id. Leave it out to let the menu switch it */
workspace?: string;
defaultWorkspace?: string;
onWorkspaceChange?: (id: string) => void;
/** The quiet line under the workspace name in the bar, like the page you're on */
context?: ReactNode;
/** The theme switch. It only reports the change: apply the theme in `onDarkChange` */
dark?: boolean;
defaultDark?: boolean;
onDarkChange?: (dark: boolean) => void;
/** Defaults to "Dark theme" */
themeLabel?: string;
/** The list Keyboard shortcuts opens. Defaults to the keys this menu answers to */
shortcuts?: ProfileMenuShortcut[];
/** Whether that list is open. Keyboard shortcuts, or ? anywhere in the component, opens it */
shortcutsOpen?: boolean;
defaultShortcutsOpen?: boolean;
onShortcutsOpenChange?: (open: boolean) => void;
/** Sign out swaps the avatar for a Sign in button, which swaps it back */
signedIn?: boolean;
defaultSignedIn?: boolean;
onSignedInChange?: (signedIn: boolean) => void;
/** A confirmation under the bar, like "Switched to Acme Labs". Shown with a green check */
note?: string;
/** Whether the menu is open. Leave it out to let the avatar, Escape and a click outside open and close it */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
const SHORTCUTS: ProfileMenuShortcut[] = [
{ keys: ['↑', '↓'], label: 'Move through the menu' }, { keys: ['Home', 'End'], label: 'First or last item' },
{ keys: ['Esc'], label: 'Close the menu' }, { keys: ['?'], label: 'Show these shortcuts' }
];
/** The account menu behind an avatar: who is signed in, the workspaces to switch between, a theme switch, keyboard shortcuts and Sign out. */
export function ProfileMenu({
user, workspaces, workspace, defaultWorkspace, onWorkspaceChange, context, dark, defaultDark = false, onDarkChange, themeLabel = 'Dark theme',
shortcuts = SHORTCUTS, shortcutsOpen, defaultShortcutsOpen = false, onShortcutsOpenChange, signedIn, defaultSignedIn = true, onSignedInChange,
note, open, defaultOpen = false, onOpenChange, className
}: ProfileMenuProps) {
const [ws, setWs] = useControllable(workspace, defaultWorkspace ?? workspaces[0]?.id ?? '', onWorkspaceChange);
const [isDark, setDark] = useControllable(dark, defaultDark, onDarkChange);
const [keysOpen, setKeysOpen] = useControllable(shortcutsOpen, defaultShortcutsOpen, onShortcutsOpenChange);
const [isIn, setIn] = useControllable(signedIn, defaultSignedIn, onSignedInChange);
// the menu opens downwards under the avatar, lined up with its right edge
const menu = usePopover({ align: 'end', side: 'down', open, defaultOpen, onOpenChange });
const pop = menu.pop.ref, trigger = menu.trigger.ref;
const signIn = useRef<HTMLButtonElement>(null), closeKeys = useRef<HTMLButtonElement>(null);
const id = useId();
const cur = workspaces.find(w => w.id === ws) ?? workspaces[0];
const initials = user.initials ?? user.name.split(' ').map(p => p[0]).join('').slice(0, 2).toUpperCase();
// the confirmation enters each time it changes
const [noteKey, setNoteKey] = useState(0);
useEffect(() => { if (note) setNoteKey(k => k + 1); }, [note]);
// where the focus goes once the menu has opened: the first or last item, or nowhere for a scripted click
const focusTo = useRef<'first' | 'last' | null>(null);
const items = () => Array.from(pop.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ?? []);
useEffect(() => {
if (!menu.open || !focusTo.current) return;
const all = items(); (focusTo.current === 'last' ? all[all.length - 1] : all[0])?.focus(); focusTo.current = null;
}, [menu.open]); // eslint-disable-line react-hooks/exhaustive-deps
// closing it from inside gives the focus back to the avatar
const close = (soon = false) => { if (pop.current?.contains(document.activeElement)) trigger.current?.focus(); if (soon) menu.closeSoon(); else menu.close(); };
const onTrigger = (e: MouseEvent) => { if (!menu.open && e.nativeEvent.isTrusted) focusTo.current = 'first'; menu.trigger.onClick(); };
const onTriggerKey = (e: KeyboardEvent) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault(); focusTo.current = e.key === 'ArrowUp' ? 'last' : 'first';
if (menu.open) { const all = items(); (focusTo.current === 'last' ? all[all.length - 1] : all[0])?.focus(); focusTo.current = null; } else menu.setOpen(true);
};
// inside the menu: the arrow keys move and wrap, Home and End jump to the ends, Tab leaves it (Escape is the popover's)
const onMenuKey = (e: KeyboardEvent) => {
const all = items(), i = all.indexOf(document.activeElement as HTMLElement);
const go = ({ ArrowDown: all[(i + 1) % all.length], ArrowUp: all[(i - 1 + all.length) % all.length], Home: all[0], End: all[all.length - 1] } as Record<string, HTMLElement | undefined>)[e.key];
if (go) { e.preventDefault(); go.focus(); } else if (e.key === 'Tab') setTimeout(() => menu.close());
};
const showKeys = (on: boolean) => setKeysOpen(on);
// the menu opens over whatever sits under the bar, so the component keeps room for it below the bar: centred in a
// stage, the open menu still ends inside it
const bar = useRef<HTMLDivElement>(null);
const [room, setRoom] = useState<number>();
useEffect(() => {
const fit = () => { if (bar.current && pop.current) setRoom(Math.ceil(bar.current.offsetHeight + 6 + pop.current.offsetHeight)); };
fit(); const ro = new ResizeObserver(fit);
if (bar.current) ro.observe(bar.current); if (pop.current) ro.observe(pop.current);
return () => ro.disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div data-dm="" className={cx('dm-pfm', className)} style={room ? { minHeight: room } : undefined}
onKeyDown={e => { if (e.key === '?' && !(e.target as HTMLElement).matches('input,textarea')) { e.preventDefault(); menu.close(); showKeys(!keysOpen); } }}>
<div ref={bar} className="dm-pfm-bar dm-panel">
{cur && <span className="dm-pfm-lg" data-t={cur.tint}>{cur.initial ?? cur.name[0]}</span>}
<span className="dm-pfm-ws"><Swap className="dm-pfm-wn">{cur?.name ?? ''}</Swap>{context && <span className="dm-muted">{context}</span>}</span>
{isIn ? (
<button {...menu.trigger} onClick={onTrigger} onKeyDown={onTriggerKey} type="button" className="dm-pfm-tr" aria-label={`Account menu, ${user.name}`}>
<span className="dm-av" style={user.color ? { background: user.color, color: avatarInk(user.color) } : undefined}>{initials}</span><Icon name="chev-d" size="xs" />
</button>
) : (
<button ref={signIn} type="button" className="dm-pill dm-line dm-sm dm-pfm-in" onClick={() => { setIn(true); setTimeout(() => trigger.current?.focus()); }}>Sign in</button>
)}
</div>
<div {...menu.pop} className={cx(menu.pop.className, 'dm-pfm-pop')} role="menu" aria-labelledby={`${id}-n ${id}-e`} onKeyDown={onMenuKey}>
<div className="dm-pfm-me" role="none">
<span className="dm-av" style={user.color ? { background: user.color, color: avatarInk(user.color) } : undefined}>{initials}</span>
<span className="dm-t"><b id={`${id}-n`}>{user.name}</b><span className="dm-muted" id={`${id}-e`}>{user.email}</span></span>
</div>
<div className="dm-sep" role="separator" />
<div role="group" aria-labelledby={`${id}-w`}>
<div className="dm-ph" role="none" id={`${id}-w`}>Workspaces</div>
{workspaces.map(w => (
<button key={w.id} type="button" className="dm-mi" role="menuitemradio" aria-checked={w.id === cur?.id} tabIndex={-1} onClick={() => { setWs(w.id); close(true); }}>
<span className="dm-pfm-lg" data-t={w.tint}>{w.initial ?? w.name[0]}</span>
<span>{w.name}{w.detail && <span className="dm-d">{w.detail}</span>}</span>
<span className="dm-ck"><Icon name="check" size="xs" /></span>
</button>
))}
</div>
<div className="dm-sep" role="separator" />
<button type="button" className="dm-mi dm-pfm-th" role="menuitemcheckbox" aria-checked={isDark} tabIndex={-1} onClick={() => setDark(!isDark)}>
<Icon name="moon" /><span>{themeLabel}</span><span className="dm-toggle" aria-hidden="true" />
</button>
<button type="button" className="dm-mi dm-pfm-ky" role="menuitem" tabIndex={-1} onClick={() => { const had = pop.current?.contains(document.activeElement); menu.close(); showKeys(true); if (had) setTimeout(() => closeKeys.current?.focus()); }}>
<Icon name="keyboard" /><span>Keyboard shortcuts</span><span className="dm-kbd" aria-hidden="true">?</span>
</button>
<div className="dm-sep" role="separator" />
<button type="button" className="dm-mi dm-pfm-out" role="menuitem" tabIndex={-1} onClick={() => { const had = pop.current?.contains(document.activeElement); menu.close(); setIn(false); if (had) setTimeout(() => signIn.current?.focus()); }}>
<Icon name="logout" /><span>Sign out</span>
</button>
</div>
<Fold open={keysOpen} className="dm-pfm-kf">
<div className="dm-pfm-keys dm-panel" role="region" aria-label="Keyboard shortcuts">
<div className="dm-pfm-kh"><b>Keyboard shortcuts</b>
<Tip label="Close"><button ref={closeKeys} type="button" className="dm-ib dm-pfm-kx" aria-label="Close shortcuts" onClick={() => { showKeys(false); trigger.current?.focus(); }}><Icon name="x" size="xs" /></button></Tip>
</div>
<dl className="dm-pfm-kl">
{shortcuts.map(s => <div key={s.label}><dt>{s.keys.map(k => <span key={k} className="dm-kbd">{k}</span>)}</dt><dd>{s.label}</dd></div>)}
</dl>
</div>
</Fold>
<p className="dm-pfm-note dm-muted" aria-live="polite">
{note && <><Icon name="ok" size="xs" /><span key={noteKey} {...enter()}>{note}</span></>}
</p>
</div>
);
}
profile-menu.cssReact · 47 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Profile menu ---------- */
/* an app's top bar with the account behind the avatar. The menu opens under it, lined up with its right edge */
.dm-pfm{position:relative;width:100%;max-width:400px;margin:0 auto;display:flex;flex-direction:column;gap:10px}
.dm-pfm-bar{display:flex;align-items:center;gap:10px;min-height:52px;padding:var(--dm-s-2) var(--dm-s-2) var(--dm-s-2) var(--dm-s-3);border-radius:14px}
.dm-pfm-lg{width:26px;height:26px;border-radius:var(--dm-r-sm);display:grid;place-items:center;flex:none;font-size:12px;font-weight:500;background:var(--dm-chip);color:var(--dm-fg2);transition:background var(--dm-t) var(--dm-ease),color var(--dm-t) var(--dm-ease)}
.dm-pfm-lg[data-t="pb"]{--dm-tb:var(--dm-pb-bg);--dm-tc:var(--dm-pb)}.dm-pfm-lg[data-t="pp"]{--dm-tb:var(--dm-pp-bg);--dm-tc:var(--dm-pp)}.dm-pfm-lg[data-t="pl"]{--dm-tb:var(--dm-pl-bg);--dm-tc:var(--dm-pl)}.dm-pfm-lg[data-t="pm"]{--dm-tb:var(--dm-pm-bg);--dm-tc:var(--dm-pm)}
.dm-pfm-lg[data-t="pr"]{--dm-tb:var(--dm-pr-bg);--dm-tc:var(--dm-pr)}.dm-pfm-lg[data-t="py"]{--dm-tb:var(--dm-py-bg);--dm-tc:var(--dm-py)}.dm-pfm-lg[data-t="pk"]{--dm-tb:var(--dm-pk-bg);--dm-tc:var(--dm-pk)}.dm-pfm-lg[data-t="pt"]{--dm-tb:var(--dm-pt-bg);--dm-tc:var(--dm-pt)}
/* the letter leans a little toward the text colour, so it reads at 4.5:1 on its tint in dark mode too, on a hovered row as well */
.dm-pfm-lg[data-t]{background:var(--dm-tb);color:color-mix(in srgb,var(--dm-tc) 80%,var(--dm-fg))}
.dm-pfm-ws{flex:1;min-width:0;display:flex;flex-direction:column;line-height:1.3}
.dm-pfm-wn{font-size:13.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.dm-pfm-ws .dm-muted{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-pfm-tr{display:flex;align-items:center;gap:var(--dm-s-1);height:36px;padding:0 7px 0 var(--dm-s-1);border-radius:var(--dm-r-pill);color:var(--dm-muted);flex:none;transition:background var(--dm-t-fast)}
.dm-pfm-tr[aria-expanded="true"]{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-pfm-tr:hover{background:var(--dm-row)}}
.dm-pfm-tr .dm-av,.dm-pfm-me .dm-av{background:var(--dm-av-l);color:var(--dm-av-l-ink)}
.dm-pfm-tr .dm-av{width:28px;height:28px;font-size:11px}
.dm-pfm-tr>.dm-i{transition:transform var(--dm-t) var(--dm-ease)}
.dm-pfm-tr[aria-expanded="true"]>.dm-i{transform:rotate(180deg)}
.dm-pfm-in{flex:none}
.dm-pfm-pop{width:256px}
.dm-pfm-me{display:flex;align-items:center;gap:10px;padding:var(--dm-s-2) var(--dm-s-2) 6px}
.dm-pfm-me .dm-av{width:32px;height:32px;font-size:12px}
.dm-pfm-me .dm-t{min-width:0;display:flex;flex-direction:column;line-height:1.35}
.dm-pfm-me b{font-size:13.5px;font-weight:500}
.dm-pfm-me .dm-muted{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-pfm-pop .dm-mi .dm-pfm-lg{width:24px;height:24px;font-size:11px;border-radius:7px}
.dm-pfm-pop .dm-mi .dm-d{white-space:nowrap}
/* the theme row is a switch: on, it shows the switch on rather than a selected row */
.dm-pfm-th[aria-checked="true"]{background:none;font-weight:400}
.dm-pfm-th.dm-hl{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-pfm-th[aria-checked="true"]:hover{background:var(--dm-row)}}
.dm-pfm-th .dm-toggle{margin-left:auto}
.dm-pfm-th[aria-checked="true"] .dm-toggle{background:var(--dm-accent);box-shadow:inset 0 0 0 1px var(--dm-accent-mark,var(--dm-accent))}
.dm-pfm-th[aria-checked="true"] .dm-toggle::after{transform:translateX(12px);background:var(--dm-on-accent)}
.dm-pfm-ky .dm-kbd{margin-left:auto}
.dm-pfm-keys{padding:10px var(--dm-s-3) var(--dm-s-3);border-radius:14px}
.dm-pfm-kh{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-2);font-size:13px;margin-bottom:6px}
.dm-pfm-kh .dm-ib{width:26px;height:26px;margin-right:-4px}
.dm-pfm-kl{margin:0;display:flex;flex-direction:column;gap:6px}
.dm-pfm-kl>div{display:flex;align-items:center;gap:10px;font-size:12.5px}
.dm-pfm-kl dt{display:flex;gap:var(--dm-s-1);width:88px;flex:none}
.dm-pfm-kl dd{margin:0;color:var(--dm-fg2);min-width:0}
.dm-pfm-note{margin:0;min-height:20px;display:flex;align-items:center;justify-content:center;gap:6px;font-size:12.5px;text-align:center}
.dm-pfm-note .dm-i{color:var(--dm-ok)}
profile-menu.htmlHTML · 27 lines
<div class="card" data-c="profile-menu">
<div class="stage-in">
<div class="pfm">
<div class="pfm-bar panel">
<span class="pfm-lg pfm-cur"></span><span class="pfm-ws"><b class="pfm-wn"></b><span class="muted">Pricing refresh</span></span>
<button class="pfm-tr" aria-haspopup="menu" aria-expanded="false" aria-label="Account menu, Maya Kim" data-pop-align="end" data-pop-pos="down"><span class="av">MK</span><svg class="i xs"><use href="#i-chev-d"/></svg></button>
<button class="pill line sm pfm-in" hidden>Sign in</button>
</div>
<div class="pop pfm-pop" role="menu">
<div class="pfm-me" role="none"><span class="av">MK</span><span class="t"><b class="pfm-nm">Maya Kim</b><span class="muted pfm-em">[email protected]</span></span></div>
<div class="sep" role="separator"></div>
<div class="pfm-grp" role="group"><div class="ph pfm-gl" role="none">Workspaces</div><div class="pfm-wsl"></div></div>
<div class="sep" role="separator"></div>
<button class="mi pfm-th" role="menuitemcheckbox" aria-checked="false" tabindex="-1"><svg class="i s"><use href="#i-moon"/></svg><span>Dark theme</span><span class="toggle" aria-hidden="true"></span></button>
<button class="mi pfm-ky" role="menuitem" tabindex="-1"><svg class="i s"><use href="#i-keyboard"/></svg><span>Keyboard shortcuts</span><span class="kbd" aria-hidden="true">?</span></button>
<div class="sep" role="separator"></div>
<button class="mi pfm-out" role="menuitem" tabindex="-1"><svg class="i s"><use href="#i-logout"/></svg><span>Sign out</span></button>
</div>
<div class="fold shut pfm-kf"><div><div class="pfm-keys panel" role="region" aria-label="Keyboard shortcuts">
<div class="pfm-kh"><b>Keyboard shortcuts</b><button class="ib pfm-kx" aria-label="Close shortcuts" data-tip="Close"><svg class="i xs"><use href="#i-x"/></svg></button></div>
<dl class="pfm-kl"><div><dt><span class="kbd">↑</span><span class="kbd">↓</span></dt><dd>Move through the menu</dd></div><div><dt><span class="kbd">Home</span><span class="kbd">End</span></dt><dd>First or last item</dd></div><div><dt><span class="kbd">Esc</span></dt><dd>Close the menu</dd></div><div><dt><span class="kbd">?</span></dt><dd>Show these shortcuts</dd></div></dl>
</div></div></div>
<p class="pfm-note muted" aria-live="polite"></p>
</div>
</div>
</div>
profile-menu.cssHTML · 46 lines
/* ---------- Profile menu ---------- */
/* an app's top bar with the account behind the avatar. The menu opens under it, lined up with its right edge */
.pfm{position:relative;width:100%;max-width:400px;margin:0 auto;display:flex;flex-direction:column;gap:10px}
.pfm-bar{display:flex;align-items:center;gap:10px;min-height:52px;padding:var(--s-2) var(--s-2) var(--s-2) var(--s-3);border-radius:14px}
.pfm-lg{width:26px;height:26px;border-radius:var(--r-sm);display:grid;place-items:center;flex:none;font-size:12px;font-weight:500;background:var(--chip);color:var(--fg2);transition:background var(--t) var(--ease),color var(--t) var(--ease)}
.pfm-lg[data-t="pb"]{--tb:var(--pb-bg);--tc:var(--pb)}.pfm-lg[data-t="pp"]{--tb:var(--pp-bg);--tc:var(--pp)}.pfm-lg[data-t="pl"]{--tb:var(--pl-bg);--tc:var(--pl)}.pfm-lg[data-t="pm"]{--tb:var(--pm-bg);--tc:var(--pm)}
.pfm-lg[data-t="pr"]{--tb:var(--pr-bg);--tc:var(--pr)}.pfm-lg[data-t="py"]{--tb:var(--py-bg);--tc:var(--py)}.pfm-lg[data-t="pk"]{--tb:var(--pk-bg);--tc:var(--pk)}.pfm-lg[data-t="pt"]{--tb:var(--pt-bg);--tc:var(--pt)}
/* the letter leans a little toward the text colour, so it reads at 4.5:1 on its tint in dark mode too, on a hovered row as well */
.pfm-lg[data-t]{background:var(--tb);color:color-mix(in srgb,var(--tc) 80%,var(--fg))}
.pfm-ws{flex:1;min-width:0;display:flex;flex-direction:column;line-height:1.3}
.pfm-wn{font-size:13.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.pfm-ws .muted{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pfm-tr{display:flex;align-items:center;gap:var(--s-1);height:36px;padding:0 7px 0 var(--s-1);border-radius:var(--r-pill);color:var(--muted);flex:none;transition:background var(--t-fast)}
.pfm-tr[aria-expanded="true"]{background:var(--row)}
@media (hover:hover) and (pointer:fine){.pfm-tr:hover{background:var(--row)}}
.pfm-tr .av,.pfm-me .av{background:var(--av-l);color:var(--av-l-ink)}
.pfm-tr .av{width:28px;height:28px;font-size:11px}
.pfm-tr>.i{transition:transform var(--t) var(--ease)}
.pfm-tr[aria-expanded="true"]>.i{transform:rotate(180deg)}
.pfm-in{flex:none}
.pfm-pop{width:256px}
.pfm-me{display:flex;align-items:center;gap:10px;padding:var(--s-2) var(--s-2) 6px}
.pfm-me .av{width:32px;height:32px;font-size:12px}
.pfm-me .t{min-width:0;display:flex;flex-direction:column;line-height:1.35}
.pfm-me b{font-size:13.5px;font-weight:500}
.pfm-me .muted{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pfm-pop .mi .pfm-lg{width:24px;height:24px;font-size:11px;border-radius:7px}
.pfm-pop .mi .d{white-space:nowrap}
/* the theme row is a switch: on, it shows the switch on rather than a selected row */
.pfm-th[aria-checked="true"]{background:none;font-weight:400}
.pfm-th.hl{background:var(--row)}
@media (hover:hover) and (pointer:fine){.pfm-th[aria-checked="true"]:hover{background:var(--row)}}
.pfm-th .toggle{margin-left:auto}
.pfm-th[aria-checked="true"] .toggle{background:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-mark,var(--accent))}
.pfm-th[aria-checked="true"] .toggle::after{transform:translateX(12px);background:var(--on-accent)}
.pfm-ky .kbd{margin-left:auto}
.pfm-keys{padding:10px var(--s-3) var(--s-3);border-radius:14px}
.pfm-kh{display:flex;align-items:center;justify-content:space-between;gap:var(--s-2);font-size:13px;margin-bottom:6px}
.pfm-kh .ib{width:26px;height:26px;margin-right:-4px}
.pfm-kl{margin:0;display:flex;flex-direction:column;gap:6px}
.pfm-kl>div{display:flex;align-items:center;gap:10px;font-size:12.5px}
.pfm-kl dt{display:flex;gap:var(--s-1);width:88px;flex:none}
.pfm-kl dd{margin:0;color:var(--fg2);min-width:0}
.pfm-note{margin:0;min-height:20px;display:flex;align-items:center;justify-content:center;gap:6px;font-size:12.5px;text-align:center}
.pfm-note .i{color:var(--ok)}
profile-menu.jsHTML · 100 lines
DM.def('profile-menu', (r, c) => {
r.innerHTML = r.innerHTML.replace('{chev}', I('chev-d','xs')).replace('{moon}', I('moon')).replace('{keyboard}', I('keyboard')).replace('{logout}', I('logout')).replace('{x}', I('x','xs'));
// the workspaces this account belongs to: initial, name, plan and the tint of its tile
const WS = [['N', 'Northwind', 'Pro, 12 members', 'pb'], ['A', 'Acme Labs', 'Free, 3 members', 'pp'], ['P', 'Personal', 'Just you', 'pl']];
const uid = Math.random().toString(36).slice(2, 7);
const tr = $('.pfm-tr', r), pop = $('.pfm-pop', r), wsl = $('.pfm-wsl', r), th = $('.pfm-th', r), note = $('.pfm-note', r), kf = $('.pfm-kf', r), sin = $('.pfm-in', r), cur = $('.pfm-cur', r), wn = $('.pfm-wn', r);
// the menu is named by the account it belongs to, and the workspaces by their heading
$('.pfm-nm', r).id = 'pfm-nm-' + uid; $('.pfm-em', r).id = 'pfm-em-' + uid; $('.pfm-gl', r).id = 'pfm-gl-' + uid;
pop.setAttribute('aria-labelledby', `pfm-nm-${uid} pfm-em-${uid}`); $('.pfm-grp', r).setAttribute('aria-labelledby', 'pfm-gl-' + uid);
wsl.innerHTML = WS.map((w, i) => `<button class="mi" role="menuitemradio" aria-checked="${!i}" tabindex="-1" data-i="${i}"><span class="pfm-lg" data-t="${w[3]}">${w[0]}</span><span>${w[1]}<span class="d">${w[2]}</span></span><span class="ck">${I('check','xs')}</span></button>`).join('');
const rows = $$('.mi', wsl), items = () => $$('.mi', pop);
// the theme switch starts from the page's own theme
const root = document.documentElement;
let ws = 0, dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
const say = t => { note.innerHTML = t ? `${I('ok','xs')}<span>${t}</span>` : ''; if (t) enter(note); };
function paint(){
const w = WS[ws]; cur.textContent = w[0]; cur.dataset.t = w[3];
rows.forEach((m, i) => m.setAttribute('aria-checked', i === ws)); th.setAttribute('aria-checked', dark);
}
// the workspace name in the bar crossfades to the new one. Each switch replaces the one before it, so switching again
// before the fade has finished (or straight back) still ends on the workspace you picked, never on an empty line
let named = WS[0][1], nameT = 0;
const setName = t => {
clearTimeout(nameT); wn.classList.add('sw');
if (t === named){ wn.textContent = t; wn.classList.remove('out'); return; }
wn.classList.add('out');
nameT = setTimeout(() => { named = t; wn.textContent = t; wn.classList.remove('out'); }, REDUCE ? 0 : 170);
};
c.offs.push(() => clearTimeout(nameT));
wn.textContent = named; paint();
// the menu opens and closes on its own rather than through the page's shared popover, so a story playing in the card
// beside it can't close it. Closing it while the focus is inside gives the focus back to the avatar
const isOpen = () => pop.classList.contains('open');
// the focus is the one highlight: a row the demo's pointer left lit goes dark
const focusItem = m => { if (!m) return; items().forEach(x => { x.tabIndex = x === m ? 0 : -1; x.classList.remove('hl'); }); m.focus({ preventScroll:true }); };
function show(on, focus){
if (on && !isOpen()){ hideTip(); placePop(tr, pop); }
pop.classList.toggle('open', on); pop.inert = !on; tr.setAttribute('aria-expanded', on);
if (on && focus){ const all = items(); focusItem(focus === 'last' ? all[all.length - 1] : all[0]); }
if (!on){ items().forEach(x => x.classList.remove('hl')); if (pop.contains(document.activeElement)) tr.focus({ preventScroll:true }); }
}
show(false);
// the menu opens over whatever sits under the bar, so the component keeps room for it below the bar: centred in a
// stage, the open menu still ends inside it
const room = () => { const el = $('.pfm', r); el.style.minHeight = Math.ceil($('.pfm-bar', r).offsetHeight + 6 + pop.offsetHeight) + 'px'; };
room(); if (document.fonts) document.fonts.ready.then(() => { if (!c.dead) room(); });
c.on(window, 'resize', room);
// the avatar opens it with a click, Enter, Space or the down arrow, and the focus goes to the first item; the up arrow
// opens it on the last. A story's tap opens it without moving the focus
c.on(tr, 'click', e => show(!isOpen(), e.isTrusted && 'first'));
c.on(tr, 'keydown', e => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){ e.preventDefault(); show(true, e.key === 'ArrowUp' ? 'last' : 'first'); } });
c.on(document, 'pointerdown', e => { if (isOpen() && !pop.contains(e.target) && !tr.contains(e.target)) show(false); });
c.on(window, 'resize', e => { if (e.isTrusted) show(false); });
// inside the menu: the arrow keys move and wrap, Home and End jump to the ends, Escape closes it, Tab leaves it
c.on(pop, 'keydown', e => {
const all = items(), i = all.indexOf(document.activeElement);
const go = { ArrowDown:all[(i + 1) % all.length], ArrowUp:all[(i - 1 + all.length) % all.length], Home:all[0], End:all[all.length - 1] }[e.key];
if (go){ e.preventDefault(); focusItem(go); }
else if (e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); show(false); }
else if (e.key === 'Tab') setTimeout(() => show(false));
});
rows.forEach((m, i) => m.addEventListener('click', () => {
show(false); if (i === ws) return;
ws = i; paint(); setName(WS[ws][1]); say(`Switched to ${WS[ws][1]}`);
}));
// the theme is a switch: the menu stays open so the change shows where it was made
// a real click switches the page too, through its own theme button where there is one; the preview's story only flips the
// switch, so the page doesn't flash between themes while the demo loops
const pageDark = () => root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
th.addEventListener('click', e => {
dark = !dark; paint(); say(`Theme set to ${dark ? 'dark' : 'light'}`);
if (!e.isTrusted || pageDark() === dark) return;
const btn = document.getElementById('theme-btn');
if (btn) btn.click(); else { root.dataset.theme = dark ? 'dark' : 'light'; try { localStorage.setItem('dmui-theme', root.dataset.theme); } catch(err){} }
});
// and when the page's theme changes from anywhere else, the switch follows it
const mo = new MutationObserver(() => { if (pageDark() !== dark){ dark = pageDark(); paint(); } });
mo.observe(root, { attributes:true, attributeFilter:['data-theme'] }); c.offs.push(() => mo.disconnect());
// Keyboard shortcuts, or ? anywhere in the component, opens the list of the keys the menu answers to
const keys = on => { kf.classList.toggle('shut', !on); kf.inert = !on; };
keys(false);
$('.pfm-ky', r).addEventListener('click', () => { const had = pop.contains(document.activeElement); show(false); keys(true); if (had) $('.pfm-kx', r).focus({ preventScroll:true }); });
$('.pfm-kx', r).onclick = () => { keys(false); tr.focus({ preventScroll:true }); };
c.on(r, 'keydown', e => { if (e.key === '?' && !e.target.matches('input,textarea')){ e.preventDefault(); show(false); keys(kf.classList.contains('shut')); } });
// signing out swaps the avatar for Sign in, which brings it back
$('.pfm-out', r).addEventListener('click', () => { const had = pop.contains(document.activeElement); show(false); tr.hidden = true; sin.hidden = false; if (had) sin.focus({ preventScroll:true }); say(`Signed out of ${WS[ws][1]}`); });
sin.onclick = () => { sin.hidden = true; tr.hidden = false; tr.focus({ preventScroll:true }); say('Signed in as Maya Kim'); };
// the preview's story: open the menu, look over the workspaces, switch to Acme Labs, then open it again and flip the theme
c.story(async s => {
const hl = el => items().forEach(x => x.classList.toggle('hl', x === el));
await s.beat(3); await s.tap(tr); await s.beat(2);
hl(rows[2]); await s.point(rows[2]); await s.beat(2);
hl(rows[1]); await s.point(rows[1]); await s.beat(2);
await s.tap(rows[1]); s.away(); await s.beat(4);
await s.tap(tr); await s.beat(2);
hl(th); await s.tap(th); await s.beat(3);
await s.tap(tr); s.away(); await s.beat(3);
});
});
Questions
What people ask about it.
- Does the theme switch change the theme?
- No. It flips its own state and calls
onDarkChange. Setdata-themeon<html>there, or call your own theme code. On designme.dev the demo only says what it was set to. - Can the workspaces be a submenu instead?
- They are an inline list, so the whole menu fits a phone screen without a flyout. Keep the list short; for many workspaces, link to a page that lists them all.
- What happens on Sign out?
- The avatar gives way to a Sign in button and
onSignedInChange(false)is called. Do the real sign out there, or control it withsignedIn. - Can I list my app's own shortcuts?
- Yes. Pass
shortcutsas{ keys, label }[]. Only ? is handled by the component; your other keys need your own handlers.
More in Collaboration
Threads, inboxes and boards for teams working alongside an agent.