# Velvet Sidebar: a glass sidebar for React, and five traps in blending, WebGL and the compiler

> 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.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Velvet Sidebar — Glass Collapsible Navigation Sidebar for React](https://upfork.dev/products/glass-collapsible-sidebar-react) (Component, comes with the Pro plan). Published 2026-10-26. 4 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Velvet Sidebar is a cinema-grade glass sidebar for React, made for a streaming or media app: a red active bar that travels between rows, a collapse into a monogram rail, notifications, a profile switcher with PINs, search with recent and trending queries, preview flyouts, light, dark and auto, five accents, right to left, and a drawer on phones. It's plain CSS with `--vs-*` tokens, so it needs no Tailwind.

[Live preview of Velvet Sidebar — Glass Collapsible Navigation Sidebar for React](https://upfork.dev/products/glass-collapsible-sidebar-react)

This article is for anyone, person or AI, building frosted-glass UI or a WebGL background in React: five traps we hit, each with its fix and the code that holds it.

## Glass that stays frosted

The sidebar's material is a gradient over `backdrop-filter: blur(28px) saturate(140%)`, with a film of grain and a moving sheen on top.

> **Lesson: A mix-blend-mode layer above backdrop-filter blends with the un-blurred page: the background shows through sharp** (Not in the docs; learned by the AI (Claude Opus 5.5))
> **I assumed:** the grain over the glass could use a blend mode, like grain over any other surface.
> **What happened:** Chrome blends a `mix-blend-mode` layer that sits above a `backdrop-filter` against the page behind it as it is, not as the glass blurs it, so the background's lines showed through the frosted glass, sharp.
> **The fix:** the grain uses normal blending, at a low opacity.
> **Evidence:** velvet-sidebar.css lines 191–201.

`components/velvet-sidebar/velvet-sidebar.css`, lines 182–201:

```css
.vs-glass {
  position: absolute;
  inset: 0;
  z-index: 0;
  background: linear-gradient(180deg, var(--vs-glass-top) 0%, var(--vs-glass-mid) 46%, var(--vs-glass-bottom) 100%);
  -webkit-backdrop-filter: blur(28px) saturate(140%);
  backdrop-filter: blur(28px) saturate(140%);
}

.vs-grain {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  /* Normal blending on purpose: Chrome blends mix-blend-mode layers against the un-blurred page behind
     backdrop-filter, which shows the background through the glass as sharp lines. */
  opacity: var(--vs-grain-opacity);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .5 0 0 0 0 .5 0 0 0 0 .5 .9 .9 .9 0 -1.2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.55'/%3E%3C/svg%3E");
  background-size: 160px 160px;
}
```

## A first fold as smooth as the rest

Collapsing the sidebar fades the labels, the search field, the section names and the wordmark out while the panel narrows to its rail.

> **Lesson: Elements that fade only when the panel folds made the first fold stutter: promote them to layers from the start** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** the browser would promote the fading elements to their own layers when the animation started, as it usually does.
> **What happened:** the first fold stuttered for about 250 ms on an Intel laptop GPU while those layers were built; every fold after it was smooth.
> **The fix:** everything that fades when the panel folds carries `will-change: opacity, transform` from the start, so its layer already exists. The first fold then runs at 60 fps like the rest.
> **Evidence:** velvet-sidebar.css lines 327–333.

`components/velvet-sidebar/velvet-sidebar.css`, lines 327–333:

```css
/*
 * What fades when the panel folds sits on its own layer from the start: built on demand, the first fold
 * stuttered for ~250ms on an Intel laptop GPU; pre-promoted, it runs at 60fps from the first time.
 */
.vs-frame :is(.vs-row__label, .vs-row__end, .vs-row__dot, .vs-card__text, .vs-card__chev, .vs-search__field, .vs-wordmark__rest, .vs-section__label, .vs-section__dash, .vs-bell) {
  will-change: opacity, transform;
}
```

## A WebGL backdrop in React

The showcase demo sits on a slowly drifting silk backdrop, drawn by a small WebGL shader, with a CSS stand-in for browsers where WebGL fails.

> **Lesson: Calling loseContext() in a WebGL effect's cleanup breaks the remount on the same canvas in React's dev mode** (My own mistake; learned by the AI (Claude Opus 5.5); react@19)
> **I assumed:** the effect's cleanup should release the GPU completely, with `WEBGL_lose_context`'s `loseContext()`.
> **What happened:** in development React runs every effect twice: run, clean up, run. The cleanup lost the context, and the second run asked the same canvas for a context that was gone, so the backdrop didn't draw.
> **The fix:** the cleanup frees what the effect made (the buffer and the program) and keeps the context, so the same canvas can mount again.
> **Evidence:** components/demo/silk-backdrop.tsx lines 202–208.

`components/demo/silk-backdrop.tsx`, lines 202–208:

```tsx
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      // Free what we made but keep the context: the same canvas can mount again (React's dev double effects).
      gl.deleteBuffer(buf);
      gl.deleteProgram(prog);
    };
```

> **Lesson: cast is a reserved word in GLSL ES: a variable named cast fails to compile, and the fallback hides it** (Not in the docs; learned by the AI (Claude Opus 5.5))
> **I assumed:** `cast` was a fine name for a variable in the shader.
> **What happened:** GLSL ES reserves `cast` for future use, and Chrome's compiler stopped with "'cast' : Illegal use of reserved word". The component did what it was built to do with a failed shader: it showed the CSS stand-in and wrote a warning to the console, so the page looked fine and only the console told the truth.
> **The fix:** the variable was renamed. Keep an eye on the console when a WebGL component has a fallback, or test that the shader compiles.
> **Evidence:** components/demo/silk-backdrop.tsx lines 136–146; the console output in the build notes.

`components/demo/silk-backdrop.tsx`, lines 136–146:

```tsx
    try {
      prog = gl.createProgram()!;
      gl.attachShader(prog, compile(gl, gl.VERTEX_SHADER, VERT));
      gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, FRAG));
      gl.linkProgram(prog);
      if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) ?? "link");
    } catch (err) {
      console.warn("Silk backdrop: WebGL shader failed, using the CSS stand-in.", err instanceof Error ? err.message : err);
      setFailed(true);
      return;
    }
```

## The React Compiler's lint rules

> **Lesson: Three React Compiler lint fixes that worked: state adjusted in render, useSyncExternalStore, and element getters** (The tools and the machine; learned by the AI (Claude Opus 5.5); react@19)
> **I assumed:** the usual patterns would pass: `setState` in an effect when a prop changes, effects that copy the theme or the text direction into state, and functions that read refs to place a popover.
> **What happened:** the React Compiler's lint rules flag all three. The first two are a synchronous `setState` in an effect; the third calls, during render, a function whose closure reads a ref.
> **The fix:** the rolling badge keeps the previous value in state and adjusts during render when it changes (the "previous value" pattern). The media queries, the theme, the text direction and the stored layout come from `useSyncExternalStore`. Popovers get their anchor as a getter, a function that is called only when the layer is placed, never while rendering.
> **Evidence:** badge.tsx lines 18–26; hooks.ts lines 43–52; popover.tsx lines 23–24.

`components/velvet-sidebar/badge.tsx`, lines 18–26:

```tsx
/** Digits that roll: each column keeps its place counted from the right, so only the digits that change move. */
export function RollingNumber({ value, max = 99 }: { value: number; max?: number }) {
  const text = formatCount(value, max);
  const [prev, setPrev] = React.useState(value);
  const [dir, setDir] = React.useState(1);
  if (prev !== value) {
    setPrev(value);
    setDir(value > prev ? 1 : -1);
  }
```

`components/velvet-sidebar/hooks.ts`, lines 43–52:

```ts
const noopSubscribe = () => () => {};

/** False on the server and during hydration, true afterwards. */
export function useIsClient(): boolean {
  return React.useSyncExternalStore(
    noopSubscribe,
    () => true,
    () => false,
  );
}
```

`components/velvet-sidebar/popover.tsx`, lines 23–24:

```tsx
/** Where an anchor's element comes from: read only when the layer is placed, never while rendering. */
export type ElementSource = () => HTMLElement | null | undefined;
```

## Checked

12 unit tests; ESLint with the React Compiler rules, TypeScript and `next build` clean; axe finds no violations on 9 screens (both themes, the rail and every popover); every label's rendered contrast is at least 4.5:1; and Chrome on an Intel laptop holds 60 fps for the active bar's travel, hovering and folding. On UpFork it passed the importer and the preview builder with all three demos.

## Make it yours

`<VelvetSidebar />` takes your `brand` and `sections` of items (icons, badges, progress, previews, shortcuts), with `value`, `collapsed` and the active profile each controlled or uncontrolled, `profiles` with an optional `verifyPin`, `notifications`, `search` and `onLogout`. An accent from five presets (or your own colour), light, dark or auto, right to left, and `labels` for every string.

```bash
npx shadcn@latest add @upfork/glass-collapsible-sidebar-react
```

---

On UpFork: https://upfork.dev/articles/velvet-sidebar-a-glass-sidebar-for-react-and-five-traps-in-blending-webgl-an
