# Duet Sidebar: a glass icon dock and an unfurling menu in React, and five traps we hit

> How Duet Sidebar's dock and menu share state without React renders, and five traps: light-dark() with numbers, self-hosted demo fonts, compiler lint, dev double effects and exiting views.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Duet Sidebar — Glassmorphism Icon Dock & Expandable Menu for React](https://upfork.dev/products/glassmorphism-icon-sidebar-menu-react) (Component, comes with the Pro plan). Published 2026-10-26. 5 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Duet Sidebar is the main navigation of a media or SaaS app, drawn as two slabs of tinted glass that work as a pair. A slim dock is always there: window dots, the brand mark, the icons and the account. Press the mark and a full menu panel unfurls beside it, with labelled rows that mirror the dock's icons one to one; hover one and its twin lights up. Inside the panel: expandable channel lists, Settings and Help views pushed iOS-style, an account switcher and search. Light, dark and auto, five tints, right to left, and a bottom dock on phones.

[Live preview of Duet Sidebar — Glassmorphism Icon Dock & Expandable Menu for React](https://upfork.dev/products/glassmorphism-icon-sidebar-menu-react)

This article is for anyone, person or AI, building a component whose parts share elements and state, in React 19 with the React Compiler's lint rules on: five traps we hit, each with its fix.

## Colours that flip with the theme

Every colour is a `--duet-*` token written once with `light-dark()`, so a theme only flips `color-scheme` and light and dark can never drift apart.

> **Lesson: light-dark() takes colours only: numbers inside it worked in Next.js through a polyfill and broke elsewhere** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **I assumed:** every theme-dependent value, including the tint's strength and the grain's opacity, could be written with `light-dark()`, like the colours.
> **What happened:** in the Next.js app it all worked, because the CSS pipeline Next.js uses rewrote `light-dark()` into variables, whatever was inside. Natively, `light-dark()` accepts colours only, so in UpFork's live preview, built without that step, those declarations were invalid and dropped: the grain showed at full strength and the dock didn't match the app.
> **The fix:** only colours go through `light-dark()`. The tint's strength per theme is written into the colour itself with `color-mix()`, and the grain is one plain number.
> **Evidence:** duet-sidebar.css lines 75–76 and 84; the preview screenshots before and after in the build notes.

`components/duet-sidebar/duet-sidebar.css`, lines 75–76:

```css
  /* light-dark() takes colours only: the tint's strength per theme is written into the colour itself. */
  --duet-tint-layer: light-dark(color-mix(in oklab, var(--duet-tint) 10%, transparent), color-mix(in oklab, var(--duet-tint) 14%, transparent));
```

## Fonts for a live preview

> **Lesson: UpFork's importer keeps only the assets the code names: next/font/local paths aren't seen, so the fonts were left out** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **I assumed:** the demo pages could load their fonts with `next/font/local`, as in the Next.js project, and the importer would bring the font files along.
> **What happened:** the importer keeps an asset only when an import, a public path or a stylesheet's `url()` names it. A path inside `next/font/local` is none of those, so the font files were left out, and the preview build would have rejected the demos.
> **The fix:** the demo stylesheet declares the fonts itself with `@font-face` and `url("./fonts/…")`, and the font files sit beside it.
> **Evidence:** components/demo/demo.css lines 6–13; the importer runs in the build notes.

`components/demo/demo.css`, lines 6–13:

```css
/* The demo fonts, self-hosted beside this file (SIL OFL 1.1): Latin from Poppins, Persian from Vazirmatn. */
@font-face {
  font-family: "Duet Poppins";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("https://upfork.dev/assets/393fc3dd908e6369ca2944a9ee037c75/components/demo/fonts/poppins-latin-400-normal.woff2") format("woff2");
}
```

## Sharing elements between parts

The dock and the panel need each other's elements: where focus goes when the panel opens, which row lights up with which icon, where the indicator slides.

> **Lesson: Refs passed through context poison every ctx read for the React Compiler: share elements through callback refs** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **I assumed:** a context holding refs and a mutable object was the natural way for the dock and the panel to share their elements.
> **What happened:** the React Compiler's lint rules treat a value read from such a context as a ref, so every `ctx.*` read during render became an error, not just the ones that touched a ref.
> **The fix:** the shared elements live in a small registry: each part registers with a stable callback ref, and the registry is read only in effects and handlers, never while rendering. One-shot hand-offs, such as where focus goes next, are methods on that object, not properties to assign. React 19's callback-ref cleanup removes an element on unmount, and only if it is still the registered one, so a copy that is animating out never unregisters the one that replaced it.
> **Evidence:** context.ts lines 88–125.

`components/duet-sidebar/context.ts`, lines 88–125:

```ts
/**
 * The sidebar's shared elements, registered by callback refs and read only in effects and handlers (never while
 * rendering), plus the one-shot hand-offs between parts: where focus goes when the panel opens, and how it folds.
 */
export type DuetDom = {
  /** A stable callback ref for a part. */
  ref(name: NodeName): (el: HTMLElement | null) => void | (() => void);
  get(name: NodeName): HTMLElement | null;
  /** A stable getter, for hooks that read the element in their effects. */
  getter(name: NodeName): () => HTMLElement | null;
  /** Where focus goes once the panel's content has mounted: "menu", "view", "search" or "row:<id>". */
  setFocus(value: string | null): void;
  takeFocus(match?: (value: string) => boolean): string | null;
  setSquash(value: boolean): void;
  squash(): boolean;
};

export function createDom(): DuetDom {
  const nodes = new Map<NodeName, HTMLElement>();
  const refs = new Map<NodeName, (el: HTMLElement | null) => void | (() => void)>();
  const getters = new Map<NodeName, () => HTMLElement | null>();
  let focus: string | null = null;
  let squash = false;
  const get = (name: NodeName) => {
    const el = nodes.get(name);
    return el && el.isConnected ? el : null;
  };
  return {
    ref(name) {
      let r = refs.get(name);
      if (!r) {
        r = (el) => {
          if (!el) return;
          nodes.set(name, el);
          // React 19 calls this on unmount; an exiting copy never removes the one that replaced it.
          return () => {
            if (nodes.get(name) === el) nodes.delete(name);
          };
```

> **Lesson: A take-once value read in an effect is lost to React's dev double effects: take it inside the frame** (My own mistake; learned by the AI (Claude Opus 5.5); react@19)
> **I assumed:** the effect that runs when the panel opens could take the pending focus target and move focus in the next animation frame.
> **What happened:** in development React runs effects twice (run, clean up, run). The first run took the value and scheduled the frame, the cleanup cancelled the frame, and the second run found nothing left to take, so focus never moved.
> **The fix:** the value is taken inside the `requestAnimationFrame` callback, so only the frame that actually runs takes it, however many times the effect ran.
> **Evidence:** menu.tsx lines 269–279.

`components/duet-sidebar/menu.tsx`, lines 269–279:

```tsx
  // Where focus goes once the panel has opened.
  useIsoLayoutEffect(() => {
    // Taken inside the frame, so React's dev double effects (run, clean up, run) still move focus once.
    const raf = requestAnimationFrame(() => {
      const want = dom.takeFocus((v) => v !== "view");
      if (!want) return;
      if (want === "search") dom.get("search")?.focus();
      else focusItem(dom.get("panelList"), want.startsWith("row:") ? want.slice(4) : bus.active());
    });
    return () => cancelAnimationFrame(raf);
  }, [dom, bus]);
```

## A view that slides out

Settings and Help are views pushed inside the panel: the menu slides 24 px away and fades while the view slides in, and "back" reverses it.

> **Lesson: A view animating out with AnimatePresence reads context that is already cleared: keep its own copy from mount** (My own mistake; learned by the AI (Claude Opus 5.5); motion@14)
> **I assumed:** the view page could read which view it shows from the sidebar's context for as long as it was on screen.
> **What happened:** after "back", the context's view is cleared at once, but AnimatePresence keeps the page on screen while it slides out, and the page lost the view it was showing.
> **The fix:** the page keeps the view's id from the moment it mounts (`useState(() => ctx.view!.id)`) and renders from that copy, so it slides out with its content.
> **Evidence:** views/view-stack.tsx lines 16–31.

`components/duet-sidebar/views/view-stack.tsx`, lines 16–31:

```tsx
export function ViewPage() {
  const ctx = useDuet();
  // Kept from mount: while the page slides out after "back", the context's view is already gone.
  const [id] = React.useState(() => ctx.view!.id);
  const info = ctx.viewInfo(id);
  const headingRef = React.useRef<HTMLHeadingElement | null>(null);
  const [scrollEl, setScrollEl] = useNode<HTMLDivElement>();
  const swipe = React.useRef<{ x: number; y: number; id: number } | null>(null);
  useScrollFade(scrollEl);

  useIsoLayoutEffect(() => {
    const raf = requestAnimationFrame(() => {
      if (ctx.dom.takeFocus((v) => v === "view")) headingRef.current?.focus({ preventScroll: true });
    });
    return () => cancelAnimationFrame(raf);
  }, []);
```

## Checked

18 unit tests; ESLint with the React Compiler rules and TypeScript clean; axe finds no violations in 12 states; the rendered text keeps a contrast of at least 4.74:1. Moving the pointer causes zero React commits, and the motion runs at 60 fps (only the very first open in a cold browser hitches). 14.6 kB gzipped of JavaScript loads eagerly. On UpFork it passed the importer and the preview builder.

## Make it yours

`<DuetSidebar />` takes your `brand` and `items` (icons, labels, groups, badges, channels, views), with `value`, `open` and `pinned` each controlled or uncontrolled, an `account` with switching and logout, `settings` and `help` content, `search`, and a `theme`, a `tint`, a `side` and a `layout`. `renderLink` lets your router's link draw the rows, `labels` translates every string, and `persistKey` remembers the open and pinned state.

```bash
npx shadcn@latest add @upfork/glassmorphism-icon-sidebar-menu-react
```

---

On UpFork: https://upfork.dev/articles/duet-sidebar-a-glass-icon-dock-and-an-unfurling-menu-in-react-and-five-traps
