
Loading the page…
Loading the page…
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.
Written with AI: Claude Opus 5.5, through Claude
5 lessons learned building it
The product
Pro plan · Component
A two-part glass sidebar for React: a slim icon dock with a glowing indicator, and a full menu that unfurls beside it. Linked hover, expandable channels, Settings and Help views, an account switcher, light/dark/auto, five tints, RTL and a phone bottom dock.
npx shadcn@latest add @upfork/glassmorphism-icon-sidebar-menu-reactDuet 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.
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.
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.
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.
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.
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.
<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.
npx shadcn@latest add @upfork/glassmorphism-icon-sidebar-menu-reactInstalls with your UpFork access token once you have it: see how on its page.
Written by
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork
Full-stack developer and founder of UpFork · .NET, React/Next.js and WebGL
This version is signed in UpFork’s ledger as entry #185, so anyone can check it hasn’t changed since it was published. Check it.
Something wrong in it, or against the rules? Report it.

Behind the build · Velvet Sidebar — Glass Collapsible Navigation Sidebar for React
How Velvet Sidebar keeps its frosted glass clean and its first fold smooth, and five traps: blend modes over backdrop-filter, layer promotion, WebGL in React's dev effects, GLSL and lint.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork4 min readAI
5 lessons


Behind the build · Perspective Cards — 3D Parallax Image Gallery Hero for React & Framer Motion
How Perspective Cards floats image cards in 3D around a headline with Motion values, and five traps: React Compiler lint, flex min-width, mobile LCP, Playwright clicks and bundle sizes.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork5 min readAI
5 lessons


Behind the build · Dynamic 3D Carousel for React & Next.js — Draggable Image & Video Slider
How Dynamic Carousel drives every card from one motion value: flick physics, a loop without clones, a 3D first paint that needs no script, and the useTransform trap Next.js hid from us.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork11 min readAI
6 lessons
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.
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.
duet-sidebar.css lines 75–76 and 84; the preview screenshots before and after in the build notes.
the demo stylesheet declares the fonts itself with @font-face and url("./fonts/…"), and the font files sit beside it.
components/demo/demo.css lines 6–13; the importer runs in the build notes.
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.
context.ts lines 88–125.
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 value is taken inside the requestAnimationFrame callback, so only the frame that actually runs takes it, however many times the effect ran.
menu.tsx lines 269–279.
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 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.
views/view-stack.tsx lines 16–31.
/* 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));/* 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");
}/**
* 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);
}; // 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]);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);
}, []);
Comments
0 comments
No comments yet. Questions, what you built with it, what could be better: its developer reads them.
Sign in to comment.
Sign in to comment