Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Safe triangle

A menu whose submenus stay open while the pointer cuts across other items to reach them, with the prediction cone drawn as it goes.

safe-triangle/safe-triangle.tsx
Live · interactive

The 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

  • Menus with submenus that open on hover to the side: File, Edit, a context menu, a nested settings list.
  • Menus where the pointer must cross other items on a diagonal to reach a submenu, and each would steal it on the way.
  • Dense desktop tools used with a mouse or a trackpad, where people move fast and expect the submenu to wait for them.
  • Teaching the pattern: tick Show prediction cone to draw the triangle the menu reasons with.

When not to use

  • Touch-first screens: there is no hover to predict. A tap opens the submenu here, but a drill-in list or a sheet reads better.
  • Flat menus with no submenus. A plain menu does the job without tracking the pointer.
  • Deep trees of three or more levels. Use a tree view, a breadcrumb list or a command palette instead.
  • Submenus that open below or to the left: the cone here aims at a submenu on the right.
  • Primary navigation for a site. Hover menus hide where things are; show the links, or use a disclosure.

You can add this component with the shadcn CLI or by hand:

Into components/safe-triangle of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/safe-triangle.json

Either way it needs React 19 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

file-menu.tsx
import { SafeTriangle } from './safe-triangle';import type { MenuItem } from './menu';// Groups of items, with a divider between groups; `items` makes a submenu.const FILE: MenuItem[][] = [  [{ id: 'new', label: 'New File', shortcut: '⌘N', keys: 'Meta+N' }],  [    {      id: 'share',      label: 'Share',      items: [        { id: 'copy-link', label: 'Copy Link' },        { id: 'export', label: 'Export…' },      ],    },  ],  [{ id: 'delete', label: 'Delete', shortcut: '⌫', keys: 'Meta+Backspace', danger: true }],];export function FileMenu({ run }: { run: (id: string) => void }) {  return <SafeTriangle groups={FILE} defaultShowCone={false} onSelect={run} autoplay={false} />;}

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.

A file menu with two submenus. On its way to a submenu, a pointer moving diagonally crosses the items in between, and each of them would take over and snap the submenu shut. A triangle from the pointer to the submenu’s near edge tells a pointer on its way from one that changed its mind; the checkbox draws it.

  • Every pointer move goes through one function that reads the boxes of the items and of the open submenu on screen, so the same code follows a mouse and the demo’s pointer, even inside a scaled container.
  • While the pointer is on the item that opened the submenu, the cone starts at the pointer; once it leaves the item, the cone stays where it left. Its far corners sit on the submenu’s near edge, a third of an item above and below it.
  • Anywhere else, a point-in-triangle test decides: inside the cone and still heading right, the pointer is on its way and the items it crosses stay dark; outside it, or heading back, the item under the pointer takes over at once.
  • A pause ends the grace: resting for 300 ms on an item inside the cone hands the menu to that item, so a reader who stops halfway is not left with the wrong submenu.
  • The cone is drawn straight into an SVG on every move, and React renders only when the lit item or the open submenu changes. Submenus are laid out while hidden, so the cone reaches one the moment it opens.
  • Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop: lavender by day, deep indigo at night. The cone is drawn in SVG with CSS classes the script only moves, so its colours follow too.
PropTypeDefaultDescription
groupsMenuItem[][]FILE_MENUThe menu’s items, in groups drawn with a divider between them. An item has an id and a label, and either a shortcut (with keys, its aria-keyshortcuts, and danger for a red action) or items, the entries of its submenu.
labelstring'File'The menu’s accessible name.
defaultShowConebooleantrueWhether the prediction cone is drawn at first. The submenus are protected either way; the checkbox switches the drawing.
defaultStateSafeTriangleState—Opens with the menu set, and turns the demo off: the lit item (active), the one with its submenu open (open), the lit submenu item (sub), the cone’s apex and the ghost pointer, by item id and shares of an item’s size.
onSelect(id: string) => void—Called with the id of the item chosen, in the menu or in a submenu.
autoplaybooleantrueWhile on screen and untouched, a demo pointer travels diagonally to both submenus, across the items in between.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

safe-triangle.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  Fragment,  useEffect,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type FocusEvent,  type KeyboardEvent,  type MouseEvent,} from 'react';import { farCorners, inBox, inTriangle, type Box, type Point } from './cone';import { GhostPointer, runDemo } from './ghost-pointer';import { FILE_MENU, type MenuItem } from './menu';import { useAutoplay } from './use-autoplay';import styles from './safe-triangle.module.css';/* * Safe triangle: a menu whose submenus stay open while the pointer travels to them.
safe-triangle.module.css
/* Safe triangle (safe-triangle.tsx): a pale lavender stage with the menu in its middle, or at   night a deep indigo one. Sizes are in em of the menu's text, which follows the stage's width (a   container): 13px from about 540px wide, down to 11px on a phone. Below the width of the menu   and a submenu side by side, the submenus overlap the menu's right edge. The menu's padding, an   item's height and a divider's match the constants the component places the submenus with.   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop   sets (data-color-scheme). */.root {  /* The stage, and the menus on it, a step up. */  --surface: #f2f4ff;  --surface-2: #fff;  /* Items; the checkbox's words; shortcuts; the drawn keys and the chevrons. */  --ink: #30354c;  --soft-ink: #4b5064;  --ink-muted: #9b9ba4;  --key: #bdbec7;  --chevron: #cfd0db;  /* The menus' hairlines and dividers, and the stage's edge. */  --line: rgb(0 0 0 / 0.06);  --edge: rgb(0 0 0 / 0.06);  /* The menus' soft shadow, far and near. */
cone.ts
/* * The prediction cone: a triangle from the pointer to the near edge of the open submenu. While * the pointer is on the item that opened the submenu, the cone starts at the pointer; once it * leaves the item, the cone stays where it left. A pointer that is still inside the cone and * still heading for the submenu is on its way there, so the items it crosses are left alone. */export type Point = { x: number; y: number };export type Box = { left: number; top: number; right: number; bottom: number };/** How far the cone reaches past the submenu's top and bottom, as a share of an item's height. */const BLEED = 0.32;export const inBox = (p: Point, box: Box) =>  p.x >= box.left && p.x < box.right && p.y >= box.top && p.y < box.bottom;/** * The cone's far corners, on the submenu's left edge: above its top and below its bottom by a * third of an item's height, so a pointer aiming at the first or the last item stays inside. */export function farCorners(submenu: Box, itemHeight: number): [Point, Point] {  const x = submenu.left + 1;  const bleed = itemHeight * BLEED;
menu.ts
/* * The menu's content: groups of items, drawn with a divider between groups. An item with `items` * opens a submenu to its right; the others are actions. */export type SubmenuItem = {  /** Unique across the whole menu. */  id: string;  label: string;};export type MenuItem = {  /** Unique across the whole menu. */  id: string;  label: string;  /** The shortcut as it is drawn, on the right: "⌘N". */  shortcut?: string;  /** The same shortcut for assistive technology, as aria-keyshortcuts spells it: "Meta+N". */  keys?: string;  /** A destructive action, drawn in red. */  danger?: boolean;  /** The items of its submenu. */  items?: SubmenuItem[];};
ghost-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's ghost pointer, and the timeline that moves it. The menu follows the pointer on every * frame, as it follows a mouse, so each glide is a requestAnimationFrame loop that places the * arrow and hands the menu its position on screen; nothing runs between glides. 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. */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** The point at a share of `target`'s width and height, in the stage's px. */  pointOn: (target: Element | null | undefined, x: number, y: number) => Point | null;  /**   * Glides the pointer to `to` (in the stage's px) over `ms`, along a path bent sideways by   * `bend` (a share of its length; 0 is a straight line).   */  glide: (to: Point | null, ms: number, bend?: number) => Promise<void>;
use-autoplay.ts
'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(      ([entry]) => {        onScreen = entry.isIntersecting;
  • It is an ARIA menu with a single tab stop: items are menuitem buttons, the two that open submenus carry aria-haspopup, aria-expanded and aria-controls, and the dividers are separators.
  • The up and down arrows (and Home, End) move through the items; the right arrow, Enter or Space opens a submenu and moves the focus into it; the left arrow or Escape closes it and brings the focus back. Choices are announced (“Chose Archive from Move to”).
  • The cone and the demo pointer are decorative and hidden from screen readers, and shortcuts are exposed as aria-keyshortcuts. On touch, a tap opens or closes a submenu.
  • Under reduced motion highlights and submenus change at once, and the demo does not play.