10 / 20
All 20 in 20 Tailwind CSS Sidebars
- 01Tailwind Accessible Sidebar Navigation ARIA Reference
- 02Tailwind Sidebar Focus Trap and Restore on Close
- 03Tailwind Inert Background Content Behind a Drawer
- 04Tailwind Sidebar Navigation Is Not a Tab List
- 05Tailwind Announcing Collapsed and Expanded Sidebar State
- 06Tailwind Roving Tabindex for Sidebar Keyboard Navigation
- 07Tailwind Persisting Sidebar Collapse State Across Reloads
- 08Tailwind Sidebar Scroll Containment with Overscroll Behavior
- 09Tailwind Nested Submenu Keyboard and Screen Reader Behavior
- 10Tailwind Resizable Sidebar with a Keyboard Accessible Handle
- 11Pure Tailwind Collapsible Sidebar with Checkbox State
- 12Tailwind Responsive Sidebar Breakpoint Behavior
- 13Pure Tailwind Icon Rail Sidebar with Accessible Labels
- 14Tailwind Dashboard Sidebar with Current Page State
- 15Pure Tailwind Multi Level Sidebar Menu Disclosure Pattern
- 16Pure Tailwind Accordion Sidebar with Details and Summary
- 17Pure Tailwind Sticky Full Height Sidebar Layout Mechanics
- 18Pure Tailwind Floating Sidebar Stacking and Overlay Order
- 19Tailwind Off Canvas Sidebar Drawer with Focus Return
- 20Pure Tailwind Sidebar Footer Pinning with Scrollable Nav
Tailwind Resizable Sidebar with a Keyboard Accessible Handle
A drag handle that only works with a mouse excludes every keyboard user, and a <div> with a col-resize cursor tells assistive technology nothing. This one is a real role="separator" with aria-valuenow, driven by pointer events AND arrow keys — and in the port the live width stays where it has to: a custom property the panel reads through w-[var(--tsb10-w)].
Mosaic by Cruip: a complete Tailwind admin dashboard template in HTML, React, Next.js and Vue · $69
Cruip · Mosaic by Cruip · Affiliate link
The code
<section class="tsb-10 block relative w-full min-h-svh bg-tsb10-bg text-tsb10-ink font-tsb10-sans leading-[1.55]" aria-label="Aperture editor">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-10-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-10-theme">
<label class="grid place-items-center w-11 h-11 border border-tsb10-line bg-tsb10-surf text-tsb10-ink cursor-pointer peer-focus-visible:outline-2 peer-focus-visible:outline-tsb10-acc peer-focus-visible:outline-offset-2" for="tsb-10-theme">
<svg class="w-4.5 h-4.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Toggle dark theme</span>
</label>
</div>
<div class="flex min-w-0 h-svh bg-tsb10-surf max-r34:flex-col max-r34:h-auto max-r34:min-h-svh" id="tsb-10-app" style="--tsb10-w: 17rem;">
<main class="flex-1 flex flex-col gap-2.5 min-w-0 p-4 bg-tsb10-bg max-r34:min-h-56">
<p class="m-0 font-tsb10-mono text-[0.72rem] text-tsb10-mut">Artboard — Meridian marketing site</p>
<div class="flex-1 min-h-0 grid grid-cols-[minmax(0,1fr)] gap-2.5 [&>span]:border [&>span]:border-tsb10-line [&>span]:bg-tsb10-surf2" aria-hidden="true">
<span class="!border-tsb10-acc"></span>
<span></span>
<span></span>
</div>
</main>
<div class="flex-none grid place-items-center w-3.5 -mx-1 z-3 cursor-col-resize bg-transparent [touch-action:none] [&:hover>span]:bg-tsb10-acc [&:hover>span]:w-1 [&:focus-visible>span]:bg-tsb10-acc [&:focus-visible>span]:w-1 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-1 max-r34:hidden" id="tsb-10-handle" role="separator" tabindex="0" aria-orientation="vertical"
aria-label="Resize layers panel" aria-valuemin="200" aria-valuemax="440" aria-valuenow="272" aria-controls="tsb-10-panel">
<span class="block w-0.5 h-full bg-tsb10-line" aria-hidden="true"></span>
</div>
<aside class="flex-none flex flex-col min-w-0 w-[var(--tsb10-w)] border-l border-tsb10-line max-r34:w-full max-r34:border-l-0 max-r34:border-t" id="tsb-10-panel">
<div class="flex items-center gap-2 px-2.5 py-2 border-b border-tsb10-line">
<p class="mr-auto m-0 font-tsb10-mono text-[0.78rem] tracking-[0.1em] uppercase text-tsb10-mut">Layers</p>
<output class="font-tsb10-mono text-[0.78rem] tabular-nums text-tsb10-acc" id="tsb-10-value">272px</output>
</div>
<nav class="flex-1 min-h-0 overflow-y-auto p-1 [overscroll-behavior:contain]" aria-label="Layer groups">
<ul class="list-none m-0 p-0">
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2 aria-[current=page]:bg-tsb10-accsoft aria-[current=page]:text-tsb10-acc aria-[current=page]:font-semibold aria-[current=page]:[&>span]:bg-tsb10-acc" href="#tsb-10-panel" aria-current="page"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Hero / masthead</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Feature grid</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Pricing table</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Footer / legal</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Annotations</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Grid guides</a></li>
</ul>
</nav>
</aside>
</div>
</section><section class="tsb-10 block relative w-full min-h-svh bg-tsb10-bg text-tsb10-ink font-tsb10-sans leading-[1.55]" aria-label="Aperture editor">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-10-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-10-theme">
<label class="grid place-items-center w-11 h-11 border border-tsb10-line bg-tsb10-surf text-tsb10-ink cursor-pointer peer-focus-visible:outline-2 peer-focus-visible:outline-tsb10-acc peer-focus-visible:outline-offset-2" for="tsb-10-theme">
<svg class="w-4.5 h-4.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Toggle dark theme</span>
</label>
</div>
<div class="flex min-w-0 h-svh bg-tsb10-surf max-r34:flex-col max-r34:h-auto max-r34:min-h-svh" id="tsb-10-app" style="--tsb10-w: 17rem;">
<main class="flex-1 flex flex-col gap-2.5 min-w-0 p-4 bg-tsb10-bg max-r34:min-h-56">
<p class="m-0 font-tsb10-mono text-[0.72rem] text-tsb10-mut">Artboard — Meridian marketing site</p>
<div class="flex-1 min-h-0 grid grid-cols-[minmax(0,1fr)] gap-2.5 [&>span]:border [&>span]:border-tsb10-line [&>span]:bg-tsb10-surf2" aria-hidden="true">
<span class="!border-tsb10-acc"></span>
<span></span>
<span></span>
</div>
</main>
<div class="flex-none grid place-items-center w-3.5 -mx-1 z-3 cursor-col-resize bg-transparent [touch-action:none] [&:hover>span]:bg-tsb10-acc [&:hover>span]:w-1 [&:focus-visible>span]:bg-tsb10-acc [&:focus-visible>span]:w-1 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-1 max-r34:hidden" id="tsb-10-handle" role="separator" tabindex="0" aria-orientation="vertical"
aria-label="Resize layers panel" aria-valuemin="200" aria-valuemax="440" aria-valuenow="272" aria-controls="tsb-10-panel">
<span class="block w-0.5 h-full bg-tsb10-line" aria-hidden="true"></span>
</div>
<aside class="flex-none flex flex-col min-w-0 w-[var(--tsb10-w)] border-l border-tsb10-line max-r34:w-full max-r34:border-l-0 max-r34:border-t" id="tsb-10-panel">
<div class="flex items-center gap-2 px-2.5 py-2 border-b border-tsb10-line">
<p class="mr-auto m-0 font-tsb10-mono text-[0.78rem] tracking-[0.1em] uppercase text-tsb10-mut">Layers</p>
<output class="font-tsb10-mono text-[0.78rem] tabular-nums text-tsb10-acc" id="tsb-10-value">272px</output>
</div>
<nav class="flex-1 min-h-0 overflow-y-auto p-1 [overscroll-behavior:contain]" aria-label="Layer groups">
<ul class="list-none m-0 p-0">
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2 aria-[current=page]:bg-tsb10-accsoft aria-[current=page]:text-tsb10-acc aria-[current=page]:font-semibold aria-[current=page]:[&>span]:bg-tsb10-acc" href="#tsb-10-panel" aria-current="page"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Hero / masthead</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Feature grid</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Pricing table</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Footer / legal</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Annotations</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Grid guides</a></li>
</ul>
</nav>
</aside>
</div>
</section>@import "tailwindcss";
@theme {
--color-tsb10-bg: #1a1a1c;
--color-tsb10-bg: oklch(22% 0.003 285);
--color-tsb10-surf: #232326;
--color-tsb10-surf: oklch(26% 0.004 285);
--color-tsb10-surf2: #2c2c30;
--color-tsb10-surf2: oklch(30% 0.005 285);
--color-tsb10-line: #3a3a3f;
--color-tsb10-line: oklch(37% 0.006 285);
--color-tsb10-ink: #ececef;
--color-tsb10-ink: oklch(94% 0.003 285);
--color-tsb10-mut: #9b9ba3;
--color-tsb10-mut: oklch(67% 0.008 285);
--color-tsb10-acc: #d8d84a;
--color-tsb10-acc: oklch(86% 0.17 105);
--color-tsb10-accsoft: #33331a;
--color-tsb10-accsoft: oklch(30% 0.05 105);
--font-tsb10-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb10-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--breakpoint-r34: 34rem;
}
/* Root rule: the full-bleed contract. The live panel width is NOT here — it is an inline
custom property on the app shell, because it changes on every pointer move and no
static utility can express it. */
.tsb-10 {
width: 100%;
min-height: 100svh;
}
.tsb-10 *,
.tsb-10 *::before,
.tsb-10 *::after {
box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette and the light mirror lives in
prefers-color-scheme: light. */
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
@media (prefers-color-scheme: light) {
.tsb-10 {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #1a1a1c;
--color-tsb10-surf: #232326;
--color-tsb10-surf2: #2c2c30;
--color-tsb10-line: #3a3a3f;
--color-tsb10-ink: #ececef;
--color-tsb10-mut: #9b9ba3;
--color-tsb10-acc: #d8d84a;
--color-tsb10-acc: oklch(86% 0.17 105);
--color-tsb10-accsoft: #33331a;
}
}
@media (prefers-color-scheme: dark) {
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
}@import "tailwindcss";
@theme {
--color-tsb10-bg: #1a1a1c;
--color-tsb10-bg: oklch(22% 0.003 285);
--color-tsb10-surf: #232326;
--color-tsb10-surf: oklch(26% 0.004 285);
--color-tsb10-surf2: #2c2c30;
--color-tsb10-surf2: oklch(30% 0.005 285);
--color-tsb10-line: #3a3a3f;
--color-tsb10-line: oklch(37% 0.006 285);
--color-tsb10-ink: #ececef;
--color-tsb10-ink: oklch(94% 0.003 285);
--color-tsb10-mut: #9b9ba3;
--color-tsb10-mut: oklch(67% 0.008 285);
--color-tsb10-acc: #d8d84a;
--color-tsb10-acc: oklch(86% 0.17 105);
--color-tsb10-accsoft: #33331a;
--color-tsb10-accsoft: oklch(30% 0.05 105);
--font-tsb10-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb10-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--breakpoint-r34: 34rem;
}
/* Root rule: the full-bleed contract. The live panel width is NOT here — it is an inline
custom property on the app shell, because it changes on every pointer move and no
static utility can express it. */
.tsb-10 {
width: 100%;
min-height: 100svh;
}
.tsb-10 *,
.tsb-10 *::before,
.tsb-10 *::after {
box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette and the light mirror lives in
prefers-color-scheme: light. */
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
@media (prefers-color-scheme: light) {
.tsb-10 {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #1a1a1c;
--color-tsb10-surf: #232326;
--color-tsb10-surf2: #2c2c30;
--color-tsb10-line: #3a3a3f;
--color-tsb10-ink: #ececef;
--color-tsb10-mut: #9b9ba3;
--color-tsb10-acc: #d8d84a;
--color-tsb10-acc: oklch(86% 0.17 105);
--color-tsb10-accsoft: #33331a;
}
}
@media (prefers-color-scheme: dark) {
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
}(function () {
document.querySelectorAll('.tsb-10').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var app = root.querySelector('#tsb-10-app'),
handle = root.querySelector('#tsb-10-handle'),
out = root.querySelector('#tsb-10-value'),
KEY = 'tsb-10:panel-w', MIN = 200, MAX = 440, w;
function clamp(n) { return Math.min(MAX, Math.max(MIN, Math.round(n))); }
try { w = clamp(Number(localStorage.getItem(KEY)) || 272); } catch (e) { w = 272; }
function set(next) {
w = clamp(next);
app.style.setProperty('--tsb10-w', w + 'px');
handle.setAttribute('aria-valuenow', String(w));
out.textContent = w + 'px';
try { localStorage.setItem(KEY, String(w)); } catch (e) { /* storage blocked: session only */ }
}
function onKey(e) {
var step = e.shiftKey ? 64 : 16,
map = { ArrowLeft: w + step, ArrowRight: w - step, Home: MAX, End: MIN };
if (!(e.key in map)) return;
e.preventDefault();
set(map[e.key]);
}
function onMove(e) { set(app.getBoundingClientRect().right - e.clientX); }
function onUp(e) { handle.releasePointerCapture(e.pointerId); handle.removeEventListener('pointermove', onMove); }
function onDown(e) {
handle.setPointerCapture(e.pointerId);
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp, { once: true });
}
handle.addEventListener('keydown', onKey);
handle.addEventListener('pointerdown', onDown);
set(w);
root.__tsbCleanup = function () {
handle.removeEventListener('keydown', onKey);
handle.removeEventListener('pointerdown', onDown);
handle.removeEventListener('pointermove', onMove);
};
});
})();(function () {
document.querySelectorAll('.tsb-10').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var app = root.querySelector('#tsb-10-app'),
handle = root.querySelector('#tsb-10-handle'),
out = root.querySelector('#tsb-10-value'),
KEY = 'tsb-10:panel-w', MIN = 200, MAX = 440, w;
function clamp(n) { return Math.min(MAX, Math.max(MIN, Math.round(n))); }
try { w = clamp(Number(localStorage.getItem(KEY)) || 272); } catch (e) { w = 272; }
function set(next) {
w = clamp(next);
app.style.setProperty('--tsb10-w', w + 'px');
handle.setAttribute('aria-valuenow', String(w));
out.textContent = w + 'px';
try { localStorage.setItem(KEY, String(w)); } catch (e) { /* storage blocked: session only */ }
}
function onKey(e) {
var step = e.shiftKey ? 64 : 16,
map = { ArrowLeft: w + step, ArrowRight: w - step, Home: MAX, End: MIN };
if (!(e.key in map)) return;
e.preventDefault();
set(map[e.key]);
}
function onMove(e) { set(app.getBoundingClientRect().right - e.clientX); }
function onUp(e) { handle.releasePointerCapture(e.pointerId); handle.removeEventListener('pointermove', onMove); }
function onDown(e) {
handle.setPointerCapture(e.pointerId);
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp, { once: true });
}
handle.addEventListener('keydown', onKey);
handle.addEventListener('pointerdown', onDown);
set(w);
root.__tsbCleanup = function () {
handle.removeEventListener('keydown', onKey);
handle.removeEventListener('pointerdown', onDown);
handle.removeEventListener('pointermove', onMove);
};
});
})();Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-tsb10-bg: #1a1a1c;
--color-tsb10-bg: oklch(22% 0.003 285);
--color-tsb10-surf: #232326;
--color-tsb10-surf: oklch(26% 0.004 285);
--color-tsb10-surf2: #2c2c30;
--color-tsb10-surf2: oklch(30% 0.005 285);
--color-tsb10-line: #3a3a3f;
--color-tsb10-line: oklch(37% 0.006 285);
--color-tsb10-ink: #ececef;
--color-tsb10-ink: oklch(94% 0.003 285);
--color-tsb10-mut: #9b9ba3;
--color-tsb10-mut: oklch(67% 0.008 285);
--color-tsb10-acc: #d8d84a;
--color-tsb10-acc: oklch(86% 0.17 105);
--color-tsb10-accsoft: #33331a;
--color-tsb10-accsoft: oklch(30% 0.05 105);
--font-tsb10-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb10-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--breakpoint-r34: 34rem;
}
/* Root rule: the full-bleed contract. The live panel width is NOT here — it is an inline
custom property on the app shell, because it changes on every pointer move and no
static utility can express it. */
.tsb-10 {
width: 100%;
min-height: 100svh;
}
.tsb-10 *,
.tsb-10 *::before,
.tsb-10 *::after {
box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette and the light mirror lives in
prefers-color-scheme: light. */
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
@media (prefers-color-scheme: light) {
.tsb-10 {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #1a1a1c;
--color-tsb10-surf: #232326;
--color-tsb10-surf2: #2c2c30;
--color-tsb10-line: #3a3a3f;
--color-tsb10-ink: #ececef;
--color-tsb10-mut: #9b9ba3;
--color-tsb10-acc: #d8d84a;
--color-tsb10-acc: oklch(86% 0.17 105);
--color-tsb10-accsoft: #33331a;
}
}
@media (prefers-color-scheme: dark) {
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
}@import "tailwindcss";
@theme {
--color-tsb10-bg: #1a1a1c;
--color-tsb10-bg: oklch(22% 0.003 285);
--color-tsb10-surf: #232326;
--color-tsb10-surf: oklch(26% 0.004 285);
--color-tsb10-surf2: #2c2c30;
--color-tsb10-surf2: oklch(30% 0.005 285);
--color-tsb10-line: #3a3a3f;
--color-tsb10-line: oklch(37% 0.006 285);
--color-tsb10-ink: #ececef;
--color-tsb10-ink: oklch(94% 0.003 285);
--color-tsb10-mut: #9b9ba3;
--color-tsb10-mut: oklch(67% 0.008 285);
--color-tsb10-acc: #d8d84a;
--color-tsb10-acc: oklch(86% 0.17 105);
--color-tsb10-accsoft: #33331a;
--color-tsb10-accsoft: oklch(30% 0.05 105);
--font-tsb10-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb10-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--breakpoint-r34: 34rem;
}
/* Root rule: the full-bleed contract. The live panel width is NOT here — it is an inline
custom property on the app shell, because it changes on every pointer move and no
static utility can express it. */
.tsb-10 {
width: 100%;
min-height: 100svh;
}
.tsb-10 *,
.tsb-10 *::before,
.tsb-10 *::after {
box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette and the light mirror lives in
prefers-color-scheme: light. */
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
@media (prefers-color-scheme: light) {
.tsb-10 {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #1a1a1c;
--color-tsb10-surf: #232326;
--color-tsb10-surf2: #2c2c30;
--color-tsb10-line: #3a3a3f;
--color-tsb10-ink: #ececef;
--color-tsb10-mut: #9b9ba3;
--color-tsb10-acc: #d8d84a;
--color-tsb10-acc: oklch(86% 0.17 105);
--color-tsb10-accsoft: #33331a;
}
}
@media (prefers-color-scheme: dark) {
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- This is the demo that defines the boundary: a continuously varying value CANNOT be a utility, so the panel reads
w-[var(--tsb10-w)]and the script writes the property. Trying to generatew-[272px]at runtime produces no CSS at all. touch-actionhas no utility and should not —[touch-action:none]on the handle is the idiomatic form, and it sits next tocursor-col-resizewhere a reader will find it.- The grip's hover and focus response is written as
[&:hover>span]:/[&:focus-visible>span]:on the handle. Agroupwould also work; the child selector is shorter and keeps the grip a presentational span with no classes of its own. -mx-1on aw-3.5handle is the whole hit-area trick: 14px of target, 6px of layout, a 2px visible line.- One
!border-tsb10-accappears on the highlighted artboard block — the only important modifier in the collection, and it is there because it overrides a[&>span]:utility set on the parent, which has equal specificity. max-r34:hiddenon the handle replaces a media-query block: on a phone the drag target is not worth its width, and the panel simply goes full width.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Resizable Sidebar with a Keyboard Accessible Handle
Source: https://codefronts.com/navigation/tailwind-css-sidebars/tailwind-resizable-sidebar-with-a-keyboard-accessible-handle/
A drag handle that only works with a mouse excludes every keyboard user, and a <div> with a col-resize cursor tells assistive technology nothing. This one is a real role="separator" with aria-valuenow, driven by pointer events AND arrow keys — and in the port the live width stays where it has to: a custom property the panel reads through w-[var(--tsb10-w)].
## HTML
```html
<section class="tsb-10 block relative w-full min-h-svh bg-tsb10-bg text-tsb10-ink font-tsb10-sans leading-[1.55]" aria-label="Aperture editor">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-10-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-10-theme">
<label class="grid place-items-center w-11 h-11 border border-tsb10-line bg-tsb10-surf text-tsb10-ink cursor-pointer peer-focus-visible:outline-2 peer-focus-visible:outline-tsb10-acc peer-focus-visible:outline-offset-2" for="tsb-10-theme">
<svg class="w-4.5 h-4.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Toggle dark theme</span>
</label>
</div>
<div class="flex min-w-0 h-svh bg-tsb10-surf max-r34:flex-col max-r34:h-auto max-r34:min-h-svh" id="tsb-10-app" style="--tsb10-w: 17rem;">
<main class="flex-1 flex flex-col gap-2.5 min-w-0 p-4 bg-tsb10-bg max-r34:min-h-56">
<p class="m-0 font-tsb10-mono text-[0.72rem] text-tsb10-mut">Artboard — Meridian marketing site</p>
<div class="flex-1 min-h-0 grid grid-cols-[minmax(0,1fr)] gap-2.5 [&>span]:border [&>span]:border-tsb10-line [&>span]:bg-tsb10-surf2" aria-hidden="true">
<span class="!border-tsb10-acc"></span>
<span></span>
<span></span>
</div>
</main>
<div class="flex-none grid place-items-center w-3.5 -mx-1 z-3 cursor-col-resize bg-transparent [touch-action:none] [&:hover>span]:bg-tsb10-acc [&:hover>span]:w-1 [&:focus-visible>span]:bg-tsb10-acc [&:focus-visible>span]:w-1 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-1 max-r34:hidden" id="tsb-10-handle" role="separator" tabindex="0" aria-orientation="vertical"
aria-label="Resize layers panel" aria-valuemin="200" aria-valuemax="440" aria-valuenow="272" aria-controls="tsb-10-panel">
<span class="block w-0.5 h-full bg-tsb10-line" aria-hidden="true"></span>
</div>
<aside class="flex-none flex flex-col min-w-0 w-[var(--tsb10-w)] border-l border-tsb10-line max-r34:w-full max-r34:border-l-0 max-r34:border-t" id="tsb-10-panel">
<div class="flex items-center gap-2 px-2.5 py-2 border-b border-tsb10-line">
<p class="mr-auto m-0 font-tsb10-mono text-[0.78rem] tracking-[0.1em] uppercase text-tsb10-mut">Layers</p>
<output class="font-tsb10-mono text-[0.78rem] tabular-nums text-tsb10-acc" id="tsb-10-value">272px</output>
</div>
<nav class="flex-1 min-h-0 overflow-y-auto p-1 [overscroll-behavior:contain]" aria-label="Layer groups">
<ul class="list-none m-0 p-0">
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2 aria-[current=page]:bg-tsb10-accsoft aria-[current=page]:text-tsb10-acc aria-[current=page]:font-semibold aria-[current=page]:[&>span]:bg-tsb10-acc" href="#tsb-10-panel" aria-current="page"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Hero / masthead</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Feature grid</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Pricing table</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Footer / legal</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Annotations</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Grid guides</a></li>
</ul>
</nav>
</aside>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tsb10-bg: #1a1a1c;
--color-tsb10-bg: oklch(22% 0.003 285);
--color-tsb10-surf: #232326;
--color-tsb10-surf: oklch(26% 0.004 285);
--color-tsb10-surf2: #2c2c30;
--color-tsb10-surf2: oklch(30% 0.005 285);
--color-tsb10-line: #3a3a3f;
--color-tsb10-line: oklch(37% 0.006 285);
--color-tsb10-ink: #ececef;
--color-tsb10-ink: oklch(94% 0.003 285);
--color-tsb10-mut: #9b9ba3;
--color-tsb10-mut: oklch(67% 0.008 285);
--color-tsb10-acc: #d8d84a;
--color-tsb10-acc: oklch(86% 0.17 105);
--color-tsb10-accsoft: #33331a;
--color-tsb10-accsoft: oklch(30% 0.05 105);
--font-tsb10-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb10-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--breakpoint-r34: 34rem;
}
/* Root rule: the full-bleed contract. The live panel width is NOT here — it is an inline
custom property on the app shell, because it changes on every pointer move and no
static utility can express it. */
.tsb-10 {
width: 100%;
min-height: 100svh;
}
.tsb-10 *,
.tsb-10 *::before,
.tsb-10 *::after {
box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette and the light mirror lives in
prefers-color-scheme: light. */
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
@media (prefers-color-scheme: light) {
.tsb-10 {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #1a1a1c;
--color-tsb10-surf: #232326;
--color-tsb10-surf2: #2c2c30;
--color-tsb10-line: #3a3a3f;
--color-tsb10-ink: #ececef;
--color-tsb10-mut: #9b9ba3;
--color-tsb10-acc: #d8d84a;
--color-tsb10-acc: oklch(86% 0.17 105);
--color-tsb10-accsoft: #33331a;
}
}
@media (prefers-color-scheme: dark) {
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.tsb-10').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var app = root.querySelector('#tsb-10-app'),
handle = root.querySelector('#tsb-10-handle'),
out = root.querySelector('#tsb-10-value'),
KEY = 'tsb-10:panel-w', MIN = 200, MAX = 440, w;
function clamp(n) { return Math.min(MAX, Math.max(MIN, Math.round(n))); }
try { w = clamp(Number(localStorage.getItem(KEY)) || 272); } catch (e) { w = 272; }
function set(next) {
w = clamp(next);
app.style.setProperty('--tsb10-w', w + 'px');
handle.setAttribute('aria-valuenow', String(w));
out.textContent = w + 'px';
try { localStorage.setItem(KEY, String(w)); } catch (e) { /* storage blocked: session only */ }
}
function onKey(e) {
var step = e.shiftKey ? 64 : 16,
map = { ArrowLeft: w + step, ArrowRight: w - step, Home: MAX, End: MIN };
if (!(e.key in map)) return;
e.preventDefault();
set(map[e.key]);
}
function onMove(e) { set(app.getBoundingClientRect().right - e.clientX); }
function onUp(e) { handle.releasePointerCapture(e.pointerId); handle.removeEventListener('pointermove', onMove); }
function onDown(e) {
handle.setPointerCapture(e.pointerId);
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp, { once: true });
}
handle.addEventListener('keydown', onKey);
handle.addEventListener('pointerdown', onDown);
set(w);
root.__tsbCleanup = function () {
handle.removeEventListener('keydown', onKey);
handle.removeEventListener('pointerdown', onDown);
handle.removeEventListener('pointermove', onMove);
};
});
})();
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Resizable Sidebar with a Keyboard Accessible Handle
Source: https://codefronts.com/navigation/tailwind-css-sidebars/tailwind-resizable-sidebar-with-a-keyboard-accessible-handle/
A drag handle that only works with a mouse excludes every keyboard user, and a <div> with a col-resize cursor tells assistive technology nothing. This one is a real role="separator" with aria-valuenow, driven by pointer events AND arrow keys — and in the port the live width stays where it has to: a custom property the panel reads through w-[var(--tsb10-w)].
## HTML
```html
<section class="tsb-10 block relative w-full min-h-svh bg-tsb10-bg text-tsb10-ink font-tsb10-sans leading-[1.55]" aria-label="Aperture editor">
<div class="absolute top-3.5 right-3.5 z-60">
<input class="tsb-10-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-10-theme">
<label class="grid place-items-center w-11 h-11 border border-tsb10-line bg-tsb10-surf text-tsb10-ink cursor-pointer peer-focus-visible:outline-2 peer-focus-visible:outline-tsb10-acc peer-focus-visible:outline-offset-2" for="tsb-10-theme">
<svg class="w-4.5 h-4.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Toggle dark theme</span>
</label>
</div>
<div class="flex min-w-0 h-svh bg-tsb10-surf max-r34:flex-col max-r34:h-auto max-r34:min-h-svh" id="tsb-10-app" style="--tsb10-w: 17rem;">
<main class="flex-1 flex flex-col gap-2.5 min-w-0 p-4 bg-tsb10-bg max-r34:min-h-56">
<p class="m-0 font-tsb10-mono text-[0.72rem] text-tsb10-mut">Artboard — Meridian marketing site</p>
<div class="flex-1 min-h-0 grid grid-cols-[minmax(0,1fr)] gap-2.5 [&>span]:border [&>span]:border-tsb10-line [&>span]:bg-tsb10-surf2" aria-hidden="true">
<span class="!border-tsb10-acc"></span>
<span></span>
<span></span>
</div>
</main>
<div class="flex-none grid place-items-center w-3.5 -mx-1 z-3 cursor-col-resize bg-transparent [touch-action:none] [&:hover>span]:bg-tsb10-acc [&:hover>span]:w-1 [&:focus-visible>span]:bg-tsb10-acc [&:focus-visible>span]:w-1 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-1 max-r34:hidden" id="tsb-10-handle" role="separator" tabindex="0" aria-orientation="vertical"
aria-label="Resize layers panel" aria-valuemin="200" aria-valuemax="440" aria-valuenow="272" aria-controls="tsb-10-panel">
<span class="block w-0.5 h-full bg-tsb10-line" aria-hidden="true"></span>
</div>
<aside class="flex-none flex flex-col min-w-0 w-[var(--tsb10-w)] border-l border-tsb10-line max-r34:w-full max-r34:border-l-0 max-r34:border-t" id="tsb-10-panel">
<div class="flex items-center gap-2 px-2.5 py-2 border-b border-tsb10-line">
<p class="mr-auto m-0 font-tsb10-mono text-[0.78rem] tracking-[0.1em] uppercase text-tsb10-mut">Layers</p>
<output class="font-tsb10-mono text-[0.78rem] tabular-nums text-tsb10-acc" id="tsb-10-value">272px</output>
</div>
<nav class="flex-1 min-h-0 overflow-y-auto p-1 [overscroll-behavior:contain]" aria-label="Layer groups">
<ul class="list-none m-0 p-0">
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2 aria-[current=page]:bg-tsb10-accsoft aria-[current=page]:text-tsb10-acc aria-[current=page]:font-semibold aria-[current=page]:[&>span]:bg-tsb10-acc" href="#tsb-10-panel" aria-current="page"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Hero / masthead</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Feature grid</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Pricing table</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Footer / legal</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Annotations</a></li>
<li><a class="flex items-center gap-2.5 min-w-0 min-h-11 px-2.5 py-2 overflow-hidden text-ellipsis whitespace-nowrap text-[0.88rem] text-tsb10-ink no-underline hover:bg-tsb10-surf2 focus-visible:outline-2 focus-visible:outline-tsb10-acc focus-visible:-outline-offset-2" href="#tsb-10-panel"><span class="flex-none w-2 h-2 bg-tsb10-mut" aria-hidden="true"></span>Grid guides</a></li>
</ul>
</nav>
</aside>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tsb10-bg: #1a1a1c;
--color-tsb10-bg: oklch(22% 0.003 285);
--color-tsb10-surf: #232326;
--color-tsb10-surf: oklch(26% 0.004 285);
--color-tsb10-surf2: #2c2c30;
--color-tsb10-surf2: oklch(30% 0.005 285);
--color-tsb10-line: #3a3a3f;
--color-tsb10-line: oklch(37% 0.006 285);
--color-tsb10-ink: #ececef;
--color-tsb10-ink: oklch(94% 0.003 285);
--color-tsb10-mut: #9b9ba3;
--color-tsb10-mut: oklch(67% 0.008 285);
--color-tsb10-acc: #d8d84a;
--color-tsb10-acc: oklch(86% 0.17 105);
--color-tsb10-accsoft: #33331a;
--color-tsb10-accsoft: oklch(30% 0.05 105);
--font-tsb10-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-tsb10-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--breakpoint-r34: 34rem;
}
/* Root rule: the full-bleed contract. The live panel width is NOT here — it is an inline
custom property on the app shell, because it changes on every pointer move and no
static utility can express it. */
.tsb-10 {
width: 100%;
min-height: 100svh;
}
.tsb-10 *,
.tsb-10 *::before,
.tsb-10 *::after {
box-sizing: border-box;
}
/* Dark-first demo: the @theme block IS the dark palette and the light mirror lives in
prefers-color-scheme: light. */
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
@media (prefers-color-scheme: light) {
.tsb-10 {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #1a1a1c;
--color-tsb10-surf: #232326;
--color-tsb10-surf2: #2c2c30;
--color-tsb10-line: #3a3a3f;
--color-tsb10-ink: #ececef;
--color-tsb10-mut: #9b9ba3;
--color-tsb10-acc: #d8d84a;
--color-tsb10-acc: oklch(86% 0.17 105);
--color-tsb10-accsoft: #33331a;
}
}
@media (prefers-color-scheme: dark) {
.tsb-10:has(.tsb-10-theme-input:checked) {
--color-tsb10-bg: #f2f2f0;
--color-tsb10-surf: #ffffff;
--color-tsb10-surf2: #e7e7e3;
--color-tsb10-line: #cfcfc9;
--color-tsb10-ink: #1a1a18;
--color-tsb10-mut: #5e5e58;
--color-tsb10-acc: #6b6b12;
--color-tsb10-acc: oklch(48% 0.13 105);
--color-tsb10-accsoft: #eaeacb;
}
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.tsb-10').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var app = root.querySelector('#tsb-10-app'),
handle = root.querySelector('#tsb-10-handle'),
out = root.querySelector('#tsb-10-value'),
KEY = 'tsb-10:panel-w', MIN = 200, MAX = 440, w;
function clamp(n) { return Math.min(MAX, Math.max(MIN, Math.round(n))); }
try { w = clamp(Number(localStorage.getItem(KEY)) || 272); } catch (e) { w = 272; }
function set(next) {
w = clamp(next);
app.style.setProperty('--tsb10-w', w + 'px');
handle.setAttribute('aria-valuenow', String(w));
out.textContent = w + 'px';
try { localStorage.setItem(KEY, String(w)); } catch (e) { /* storage blocked: session only */ }
}
function onKey(e) {
var step = e.shiftKey ? 64 : 16,
map = { ArrowLeft: w + step, ArrowRight: w - step, Home: MAX, End: MIN };
if (!(e.key in map)) return;
e.preventDefault();
set(map[e.key]);
}
function onMove(e) { set(app.getBoundingClientRect().right - e.clientX); }
function onUp(e) { handle.releasePointerCapture(e.pointerId); handle.removeEventListener('pointermove', onMove); }
function onDown(e) {
handle.setPointerCapture(e.pointerId);
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp, { once: true });
}
handle.addEventListener('keydown', onKey);
handle.addEventListener('pointerdown', onDown);
set(w);
root.__tsbCleanup = function () {
handle.removeEventListener('keydown', onKey);
handle.removeEventListener('pointerdown', onDown);
handle.removeEventListener('pointermove', onMove);
};
});
})();
```How this works
The width cannot be a utility, and that is the correct answer. It changes on every pointer move, so there is no static class for it. The panel reads a custom property instead:
<div id="tsb-10-app" style="--tsb10-w: 17rem">
…
<aside class="flex-none w-[var(--tsb10-w)]">The script sets --tsb10-w and nothing else. The rule the collection follows throughout — class strings stay static, varying values live in custom properties — is not a workaround here; it is what keeps a JIT-compiled framework compatible with continuous values.
Keyboard resize is four lines and the difference between usable and not. ←/→ move 16px, with Shift 64px, Home/End jump to the bounds. The handle is tabindex="0" and carries aria-valuenow, so the current width is announced.
Pointer capture, not document listeners. setPointerCapture keeps events on the handle even when the pointer outruns it, which removes the classic bug where a fast drag detaches. [touch-action:none] stops the browser claiming the gesture for scrolling.
The handle is presentation-free. It is a 14px hit area with a 2px grip inside, pulled into the seam with negative margins, so the visible line stays hairline-thin while the target stays draggable.
Make it yours
- Bounds live in two constants at the top of the script AND in
aria-valuemin/aria-valuemax; keep them in step or the announcement lies. - Change the keyboard step from 16/64 to suit the panel's content density.
- Persist or do not: the storage write is one line, and the pattern works without it.
- Retheme through
--color-tsb10-acc; the grip, the focus ring, the active layer and the readout share it. - Move the panel to the left edge by swapping the flex order and inverting the
onMovearithmetic. - Below 34rem the handle is hidden with
max-r34:hiddenand the panel goes full width — a drag handle on a phone is not worth the target it occupies.
Gotchas — read before shipping
- Without
[touch-action:none]the browser treats the drag as a scroll and the handle does nothing on touch. aria-valuemin/maxmust match the clamp in the script. A separator that announces 200-440 while clamping to 240 is worse than no announcement.- Use pointer capture rather than
documentlisteners; a fast drag that leaves the handle otherwise drops the pointermove stream. - A resize handle with no keyboard path is a WCAG 2.1.1 failure. It is the entire reason this demo is not a
<div>with a cursor. - Never try to compile the live width into a class.
w-[272px]assembled at runtime produces no CSS — the custom property is the only route. role="separator"is only focusable and valued when it is interactive; a decorative separator must NOT carrytabindexoraria-valuenow.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Pointer Events and setPointerCapture are Baseline since 2019; touch-action since 2018. Tailwind v4's generated sheet sets the floor here, not the resize behaviour.