28 / 28
All 28 in 28 Tailwind Hamburger Menus
- 01Tailwind Hamburger Menu with Clip-Path Circle Expansion
- 02Tailwind Keyboard Navigable Hamburger Menu with ARIA Attributes
- 03Tailwind Animated 3-Line to X Hamburger Icon Toggle
- 04Tailwind Slide-In Off-Canvas Drawer with Dimmed Backdrop Overlay
- 05Pure Tailwind Checkbox Hack Hamburger Navigation (No JavaScript)
- 06Tailwind Responsive Hamburger Menu with Integrated Search Bar & Social Links
- 07Tailwind Glassmorphism Backdrop Blur Hamburger Navigation
- 08Tailwind Push Page Navigation Menu (Off-Canvas Content Shift)
- 09Pure Tailwind Hamburger Menu Without JavaScript
- 10Tailwind Hamburger Menu Morphing to Close Icon Animation
- 11Tailwind Full Screen Mobile Navigation Overlay
- 12Tailwind Slide Out Sidebar Navigation / Off Canvas Menu
- 13Tailwind Responsive Navbar Mobile Hamburger Toggle Breakpoints
- 14Tailwind Minimalist Floating Hamburger Button
- 15Tailwind CSS Responsive Hamburger Menu Component
- 16Tailwind Rebuild of the Bootstrap 5 Navbar Hamburger Toggle
- 17Tailwind WCAG Accessible Hamburger Menu Example
- 18Pure Tailwind Dropdown Slide Down Mobile Hamburger Menu
- 19Simple 3-Line Tailwind Hamburger Icon Hover Effect
- 20Tailwind Full Screen Blur Overlay Hamburger Menu
- 21Tailwind Mobile Bottom Navigation Bar with Floating Hamburger Toggle
- 22Pure Tailwind Multi-Level Accordion Hamburger Dropdown Menu
- 23Flexbox Navbar Right-Aligned Tailwind Hamburger Icon
- 24Radial Ripple Expand Tailwind Hamburger Menu
- 25Sticky Tailwind Navbar Shrinking Hamburger Icon on Scroll
- 26Neumorphic Tailwind Hamburger Toggle Button
- 27Brutalist Design Bordered Tailwind Hamburger Menu
- 28Tailwind 3D Flip Canvas Mobile Hamburger Navigation
Tailwind 3D Flip Canvas Mobile Hamburger Navigation
The theatrical finale: the entire page tilts back in 3D — rotateY around its right edge with real perspective — swinging aside like a door to reveal the nav standing behind it, whose links counter-rotate into place. Three properties produce the drama, and in utilities all three are arbitrary on purpose: perspective, transform-origin and a composed transform are scene geometry, not design tokens.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="thb-28 w-full min-h-svh grid justify-center content-start bg-thb28-bg font-thb28-sans text-thb28-ink px-5 py-9" aria-label="3D flip canvas navigation demo">
<div class="group relative w-[min(400px,88vw)] h-135 rounded-3xl overflow-hidden border border-thb28-edge bg-thb28-bg bg-[radial-gradient(130%_100%_at_90%_50%,oklch(0.25_0.06_320/0.85),transparent_60%)] [perspective:1400px]">
<nav id="thb28-menu" aria-label="Site" class="absolute inset-y-0 right-0 left-[44%] flex flex-col gap-0.5 px-6.5 py-8.5 text-thb28-menuink opacity-35 [transform:rotateY(18deg)_translateX(40px)] [transition:transform_.6s_var(--ease-thb28),opacity_.5s] group-data-open:opacity-100 group-data-open:[transform:rotateY(0)_translateX(0)] motion-reduce:[transform:none] motion-reduce:[transition:opacity_.15s]">
<b style="--i:0" class="mb-4 font-thb28-mono text-xs font-bold tracking-[0.26em] uppercase text-thb28-acc">Menu</b>
<a href="#work" style="--i:1" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Selected work</a>
<a href="#reel" style="--i:2" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Show reel</a>
<a href="#process" style="--i:3" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Process</a>
<a href="#studio" style="--i:4" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Studio</a>
<a href="#contact" style="--i:5" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Contact</a>
<span style="--i:6" class="mt-auto font-thb28-mono text-[11.5px] text-thb28-menudim opacity-0 transition-[opacity] duration-400 group-data-open:opacity-100 group-data-open:delay-450">© 2026 — Antwerp</span>
</nav>
<div id="thb28-canvas" class="absolute inset-0 z-1 flex flex-col bg-thb28-page [transform-origin:88%_50%] [transition:transform_.6s_var(--ease-thb28),box-shadow_.6s,border-radius_.6s] group-data-open:[transform:translateX(-58%)_rotateY(34deg)_scale(0.92)] group-data-open:rounded-[18px] group-data-open:shadow-[40px_30px_80px_-30px_oklch(0.08_0.03_310/0.8)] motion-reduce:group-data-open:[transform:translateX(-58%)] motion-reduce:[transition:transform_.15s]">
<header class="flex items-center justify-between h-17 pl-6 pr-4.5 border-b border-thb28-pageedge">
<span class="text-[13px] font-extrabold tracking-[0.24em]">DEPTH.WORKS</span>
<button type="button" aria-expanded="false" aria-controls="thb28-menu" aria-label="Open menu" class="grid place-items-center w-11.5 h-11.5 rounded-xl border-0 bg-thb28-ink2 text-thb28-page cursor-pointer transition-[background,scale] duration-250 hover:scale-105 active:scale-93 focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-3 motion-reduce:transition-none">
<span class="relative block w-4.5 h-3" aria-hidden="true">
<i class="absolute left-0 top-0 w-4.5 h-0.5 rounded-sm bg-current transition-[translate,rotate] duration-300 group-data-open:translate-y-[5px] group-data-open:rotate-45 motion-reduce:transition-none"></i>
<i class="absolute left-0 top-[5px] w-4.5 h-0.5 rounded-sm bg-current transition-[opacity] duration-200 group-data-open:opacity-0 motion-reduce:transition-none"></i>
<i class="absolute left-0 top-2.5 w-4.5 h-0.5 rounded-sm bg-current transition-[translate,rotate] duration-300 group-data-open:-translate-y-[5px] group-data-open:-rotate-45 motion-reduce:transition-none"></i>
</span>
</button>
</header>
<div class="flex-1 grid content-start gap-3.5 p-5.5" aria-hidden="true">
<div class="grid content-end h-62.5 p-5 rounded-2xl bg-linear-[155deg,oklch(0.85_0.06_330),oklch(0.62_0.16_320)]">
<span class="text-[30px] font-extrabold leading-[1.02] tracking-[-0.02em] text-thb28-posterink">MOTION<br>& SPACE</span>
</div>
<div class="h-2.75 w-[74%] rounded-full bg-thb28-line"></div>
<div class="h-2.75 w-[58%] rounded-full bg-thb28-line"></div>
</div>
</div>
<button type="button" tabindex="-1" aria-hidden="true" class="absolute inset-y-0 left-0 right-[56%] z-5 hidden border-0 bg-transparent cursor-e-resize group-data-open:block"></button>
</div>
</section><section class="thb-28 w-full min-h-svh grid justify-center content-start bg-thb28-bg font-thb28-sans text-thb28-ink px-5 py-9" aria-label="3D flip canvas navigation demo">
<div class="group relative w-[min(400px,88vw)] h-135 rounded-3xl overflow-hidden border border-thb28-edge bg-thb28-bg bg-[radial-gradient(130%_100%_at_90%_50%,oklch(0.25_0.06_320/0.85),transparent_60%)] [perspective:1400px]">
<nav id="thb28-menu" aria-label="Site" class="absolute inset-y-0 right-0 left-[44%] flex flex-col gap-0.5 px-6.5 py-8.5 text-thb28-menuink opacity-35 [transform:rotateY(18deg)_translateX(40px)] [transition:transform_.6s_var(--ease-thb28),opacity_.5s] group-data-open:opacity-100 group-data-open:[transform:rotateY(0)_translateX(0)] motion-reduce:[transform:none] motion-reduce:[transition:opacity_.15s]">
<b style="--i:0" class="mb-4 font-thb28-mono text-xs font-bold tracking-[0.26em] uppercase text-thb28-acc">Menu</b>
<a href="#work" style="--i:1" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Selected work</a>
<a href="#reel" style="--i:2" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Show reel</a>
<a href="#process" style="--i:3" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Process</a>
<a href="#studio" style="--i:4" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Studio</a>
<a href="#contact" style="--i:5" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Contact</a>
<span style="--i:6" class="mt-auto font-thb28-mono text-[11.5px] text-thb28-menudim opacity-0 transition-[opacity] duration-400 group-data-open:opacity-100 group-data-open:delay-450">© 2026 — Antwerp</span>
</nav>
<div id="thb28-canvas" class="absolute inset-0 z-1 flex flex-col bg-thb28-page [transform-origin:88%_50%] [transition:transform_.6s_var(--ease-thb28),box-shadow_.6s,border-radius_.6s] group-data-open:[transform:translateX(-58%)_rotateY(34deg)_scale(0.92)] group-data-open:rounded-[18px] group-data-open:shadow-[40px_30px_80px_-30px_oklch(0.08_0.03_310/0.8)] motion-reduce:group-data-open:[transform:translateX(-58%)] motion-reduce:[transition:transform_.15s]">
<header class="flex items-center justify-between h-17 pl-6 pr-4.5 border-b border-thb28-pageedge">
<span class="text-[13px] font-extrabold tracking-[0.24em]">DEPTH.WORKS</span>
<button type="button" aria-expanded="false" aria-controls="thb28-menu" aria-label="Open menu" class="grid place-items-center w-11.5 h-11.5 rounded-xl border-0 bg-thb28-ink2 text-thb28-page cursor-pointer transition-[background,scale] duration-250 hover:scale-105 active:scale-93 focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-3 motion-reduce:transition-none">
<span class="relative block w-4.5 h-3" aria-hidden="true">
<i class="absolute left-0 top-0 w-4.5 h-0.5 rounded-sm bg-current transition-[translate,rotate] duration-300 group-data-open:translate-y-[5px] group-data-open:rotate-45 motion-reduce:transition-none"></i>
<i class="absolute left-0 top-[5px] w-4.5 h-0.5 rounded-sm bg-current transition-[opacity] duration-200 group-data-open:opacity-0 motion-reduce:transition-none"></i>
<i class="absolute left-0 top-2.5 w-4.5 h-0.5 rounded-sm bg-current transition-[translate,rotate] duration-300 group-data-open:-translate-y-[5px] group-data-open:-rotate-45 motion-reduce:transition-none"></i>
</span>
</button>
</header>
<div class="flex-1 grid content-start gap-3.5 p-5.5" aria-hidden="true">
<div class="grid content-end h-62.5 p-5 rounded-2xl bg-linear-[155deg,oklch(0.85_0.06_330),oklch(0.62_0.16_320)]">
<span class="text-[30px] font-extrabold leading-[1.02] tracking-[-0.02em] text-thb28-posterink">MOTION<br>& SPACE</span>
</div>
<div class="h-2.75 w-[74%] rounded-full bg-thb28-line"></div>
<div class="h-2.75 w-[58%] rounded-full bg-thb28-line"></div>
</div>
</div>
<button type="button" tabindex="-1" aria-hidden="true" class="absolute inset-y-0 left-0 right-[56%] z-5 hidden border-0 bg-transparent cursor-e-resize group-data-open:block"></button>
</div>
</section>@import "tailwindcss";
@theme {
--color-thb28-bg: #2a1f33;
--color-thb28-bg: oklch(0.17 0.03 310);
--color-thb28-edge: #47394f;
--color-thb28-edge: oklch(0.3 0.04 310);
--color-thb28-page: #f5f2f6;
--color-thb28-page: oklch(0.96 0.006 310);
--color-thb28-pageedge: #ded8e0;
--color-thb28-pageedge: oklch(0.88 0.01 310);
--color-thb28-line: #e3dde5;
--color-thb28-line: oklch(0.89 0.008 310);
--color-thb28-ink: #eee9f0;
--color-thb28-ink: oklch(0.94 0.01 320);
--color-thb28-ink2: #38304a;
--color-thb28-ink2: oklch(0.24 0.02 310);
--color-thb28-menuink: #eee9f0;
--color-thb28-menuink: oklch(0.94 0.01 320);
--color-thb28-menudim: #9a8fa4;
--color-thb28-menudim: oklch(0.62 0.03 320);
--color-thb28-posterink: #2f1c33;
--color-thb28-posterink: oklch(0.2 0.05 320);
--color-thb28-acc: #e069b4;
--color-thb28-acc: oklch(0.75 0.17 340);
--font-thb28-sans: 'Space Grotesk', system-ui, sans-serif;
--font-thb28-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-thb28: cubic-bezier(0.4, 0, 0.2, 1);
--ease-thb28in: cubic-bezier(0.2, 0.7, 0.2, 1);
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh. It scopes the demo, and it holds the stage at full height in the
moment before the browser build has compiled its utilities. */
.thb-28 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-thb28-bg: #2a1f33;
--color-thb28-bg: oklch(0.17 0.03 310);
--color-thb28-edge: #47394f;
--color-thb28-edge: oklch(0.3 0.04 310);
--color-thb28-page: #f5f2f6;
--color-thb28-page: oklch(0.96 0.006 310);
--color-thb28-pageedge: #ded8e0;
--color-thb28-pageedge: oklch(0.88 0.01 310);
--color-thb28-line: #e3dde5;
--color-thb28-line: oklch(0.89 0.008 310);
--color-thb28-ink: #eee9f0;
--color-thb28-ink: oklch(0.94 0.01 320);
--color-thb28-ink2: #38304a;
--color-thb28-ink2: oklch(0.24 0.02 310);
--color-thb28-menuink: #eee9f0;
--color-thb28-menuink: oklch(0.94 0.01 320);
--color-thb28-menudim: #9a8fa4;
--color-thb28-menudim: oklch(0.62 0.03 320);
--color-thb28-posterink: #2f1c33;
--color-thb28-posterink: oklch(0.2 0.05 320);
--color-thb28-acc: #e069b4;
--color-thb28-acc: oklch(0.75 0.17 340);
--font-thb28-sans: 'Space Grotesk', system-ui, sans-serif;
--font-thb28-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-thb28: cubic-bezier(0.4, 0, 0.2, 1);
--ease-thb28in: cubic-bezier(0.2, 0.7, 0.2, 1);
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh. It scopes the demo, and it holds the stage at full height in the
moment before the browser build has compiled its utilities. */
.thb-28 {
width: 100%;
min-height: 100svh;
}(function () {
document.querySelectorAll('.thb-28 > .group').forEach(function (vp) {
if (vp.dataset.bound) return; vp.dataset.bound = '1';
var btn = vp.querySelector('button[aria-controls="thb28-menu"]'),
canvas = vp.querySelector('#thb28-canvas'),
catcher = vp.querySelector('button[aria-hidden="true"]');
function set(open) {
vp.toggleAttribute('data-open', open);
btn.setAttribute('aria-expanded', String(open));
if (open) { canvas.setAttribute('inert', ''); vp.querySelector('#thb28-menu a').focus(); }
else { canvas.removeAttribute('inert'); btn.focus(); }
}
btn.addEventListener('click', function () { set(true); });
catcher.addEventListener('click', function () { set(false); });
vp.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && vp.hasAttribute('data-open')) set(false);
});
});
})();(function () {
document.querySelectorAll('.thb-28 > .group').forEach(function (vp) {
if (vp.dataset.bound) return; vp.dataset.bound = '1';
var btn = vp.querySelector('button[aria-controls="thb28-menu"]'),
canvas = vp.querySelector('#thb28-canvas'),
catcher = vp.querySelector('button[aria-hidden="true"]');
function set(open) {
vp.toggleAttribute('data-open', open);
btn.setAttribute('aria-expanded', String(open));
if (open) { canvas.setAttribute('inert', ''); vp.querySelector('#thb28-menu a').focus(); }
else { canvas.removeAttribute('inert'); btn.focus(); }
}
btn.addEventListener('click', function () { set(true); });
catcher.addEventListener('click', function () { set(false); });
vp.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && vp.hasAttribute('data-open')) set(false);
});
});
})();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-thb28-bg: #2a1f33;
--color-thb28-bg: oklch(0.17 0.03 310);
--color-thb28-edge: #47394f;
--color-thb28-edge: oklch(0.3 0.04 310);
--color-thb28-page: #f5f2f6;
--color-thb28-page: oklch(0.96 0.006 310);
--color-thb28-pageedge: #ded8e0;
--color-thb28-pageedge: oklch(0.88 0.01 310);
--color-thb28-line: #e3dde5;
--color-thb28-line: oklch(0.89 0.008 310);
--color-thb28-ink: #eee9f0;
--color-thb28-ink: oklch(0.94 0.01 320);
--color-thb28-ink2: #38304a;
--color-thb28-ink2: oklch(0.24 0.02 310);
--color-thb28-menuink: #eee9f0;
--color-thb28-menuink: oklch(0.94 0.01 320);
--color-thb28-menudim: #9a8fa4;
--color-thb28-menudim: oklch(0.62 0.03 320);
--color-thb28-posterink: #2f1c33;
--color-thb28-posterink: oklch(0.2 0.05 320);
--color-thb28-acc: #e069b4;
--color-thb28-acc: oklch(0.75 0.17 340);
--font-thb28-sans: 'Space Grotesk', system-ui, sans-serif;
--font-thb28-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-thb28: cubic-bezier(0.4, 0, 0.2, 1);
--ease-thb28in: cubic-bezier(0.2, 0.7, 0.2, 1);
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh. It scopes the demo, and it holds the stage at full height in the
moment before the browser build has compiled its utilities. */
.thb-28 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-thb28-bg: #2a1f33;
--color-thb28-bg: oklch(0.17 0.03 310);
--color-thb28-edge: #47394f;
--color-thb28-edge: oklch(0.3 0.04 310);
--color-thb28-page: #f5f2f6;
--color-thb28-page: oklch(0.96 0.006 310);
--color-thb28-pageedge: #ded8e0;
--color-thb28-pageedge: oklch(0.88 0.01 310);
--color-thb28-line: #e3dde5;
--color-thb28-line: oklch(0.89 0.008 310);
--color-thb28-ink: #eee9f0;
--color-thb28-ink: oklch(0.94 0.01 320);
--color-thb28-ink2: #38304a;
--color-thb28-ink2: oklch(0.24 0.02 310);
--color-thb28-menuink: #eee9f0;
--color-thb28-menuink: oklch(0.94 0.01 320);
--color-thb28-menudim: #9a8fa4;
--color-thb28-menudim: oklch(0.62 0.03 320);
--color-thb28-posterink: #2f1c33;
--color-thb28-posterink: oklch(0.2 0.05 320);
--color-thb28-acc: #e069b4;
--color-thb28-acc: oklch(0.75 0.17 340);
--font-thb28-sans: 'Space Grotesk', system-ui, sans-serif;
--font-thb28-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-thb28: cubic-bezier(0.4, 0, 0.2, 1);
--ease-thb28in: cubic-bezier(0.2, 0.7, 0.2, 1);
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh. It scopes the demo, and it holds the stage at full height in the
moment before the browser build has compiled its utilities. */
.thb-28 {
width: 100%;
min-height: 100svh;
}<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=Space+Grotesk:wght@400;500;600;700;800&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=Space+Grotesk:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- All three scene properties stay arbitrary, deliberately:
[perspective:1400px],[transform-origin:88%_50%]and the composed[transform:…]. None of them is a design-scale value, and Tailwind is right not to ship a scale for them. - The composed transform is kept as ONE value rather than split into
rotate-y-*andtranslate-x-*utilities. The individual properties would work, but the hinge is a tuned pair and the single value keeps that coupling visible. - The counter-rotating menu now states both of its transforms on itself, so the two planes' opposite rotations — the thing that actually sells the depth — can be compared at a glance.
- The lifted-edge shadow exists only in the
group-data-open:half, so the resting canvas pays no shadow cost and the physical grounding is declared with the motion that needs it. motion-reduce:rewrites the open state to a flattranslateX, degrading this demo into demo 08's push — expressed as two variants instead of a media-query block.inertstays a JS-set attribute, and the click-catcher stays a sibling covering the exposed left side: inside the inert subtree it would receive no pointer events, and over the menu it would eat the links.
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 3D Flip Canvas Mobile Hamburger Navigation
Source: https://codefronts.com/navigation/tailwind-hamburger-menus/tailwind-3d-flip-canvas-mobile-hamburger-navigation/
The theatrical finale: the entire page tilts back in 3D — rotateY around its right edge with real perspective — swinging aside like a door to reveal the nav standing behind it, whose links counter-rotate into place. Three properties produce the drama, and in utilities all three are arbitrary on purpose: perspective, transform-origin and a composed transform are scene geometry, not design tokens.
## HTML
```html
<section class="thb-28 w-full min-h-svh grid justify-center content-start bg-thb28-bg font-thb28-sans text-thb28-ink px-5 py-9" aria-label="3D flip canvas navigation demo">
<div class="group relative w-[min(400px,88vw)] h-135 rounded-3xl overflow-hidden border border-thb28-edge bg-thb28-bg bg-[radial-gradient(130%_100%_at_90%_50%,oklch(0.25_0.06_320/0.85),transparent_60%)] [perspective:1400px]">
<nav id="thb28-menu" aria-label="Site" class="absolute inset-y-0 right-0 left-[44%] flex flex-col gap-0.5 px-6.5 py-8.5 text-thb28-menuink opacity-35 [transform:rotateY(18deg)_translateX(40px)] [transition:transform_.6s_var(--ease-thb28),opacity_.5s] group-data-open:opacity-100 group-data-open:[transform:rotateY(0)_translateX(0)] motion-reduce:[transform:none] motion-reduce:[transition:opacity_.15s]">
<b style="--i:0" class="mb-4 font-thb28-mono text-xs font-bold tracking-[0.26em] uppercase text-thb28-acc">Menu</b>
<a href="#work" style="--i:1" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Selected work</a>
<a href="#reel" style="--i:2" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Show reel</a>
<a href="#process" style="--i:3" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Process</a>
<a href="#studio" style="--i:4" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Studio</a>
<a href="#contact" style="--i:5" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Contact</a>
<span style="--i:6" class="mt-auto font-thb28-mono text-[11.5px] text-thb28-menudim opacity-0 transition-[opacity] duration-400 group-data-open:opacity-100 group-data-open:delay-450">© 2026 — Antwerp</span>
</nav>
<div id="thb28-canvas" class="absolute inset-0 z-1 flex flex-col bg-thb28-page [transform-origin:88%_50%] [transition:transform_.6s_var(--ease-thb28),box-shadow_.6s,border-radius_.6s] group-data-open:[transform:translateX(-58%)_rotateY(34deg)_scale(0.92)] group-data-open:rounded-[18px] group-data-open:shadow-[40px_30px_80px_-30px_oklch(0.08_0.03_310/0.8)] motion-reduce:group-data-open:[transform:translateX(-58%)] motion-reduce:[transition:transform_.15s]">
<header class="flex items-center justify-between h-17 pl-6 pr-4.5 border-b border-thb28-pageedge">
<span class="text-[13px] font-extrabold tracking-[0.24em]">DEPTH.WORKS</span>
<button type="button" aria-expanded="false" aria-controls="thb28-menu" aria-label="Open menu" class="grid place-items-center w-11.5 h-11.5 rounded-xl border-0 bg-thb28-ink2 text-thb28-page cursor-pointer transition-[background,scale] duration-250 hover:scale-105 active:scale-93 focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-3 motion-reduce:transition-none">
<span class="relative block w-4.5 h-3" aria-hidden="true">
<i class="absolute left-0 top-0 w-4.5 h-0.5 rounded-sm bg-current transition-[translate,rotate] duration-300 group-data-open:translate-y-[5px] group-data-open:rotate-45 motion-reduce:transition-none"></i>
<i class="absolute left-0 top-[5px] w-4.5 h-0.5 rounded-sm bg-current transition-[opacity] duration-200 group-data-open:opacity-0 motion-reduce:transition-none"></i>
<i class="absolute left-0 top-2.5 w-4.5 h-0.5 rounded-sm bg-current transition-[translate,rotate] duration-300 group-data-open:-translate-y-[5px] group-data-open:-rotate-45 motion-reduce:transition-none"></i>
</span>
</button>
</header>
<div class="flex-1 grid content-start gap-3.5 p-5.5" aria-hidden="true">
<div class="grid content-end h-62.5 p-5 rounded-2xl bg-linear-[155deg,oklch(0.85_0.06_330),oklch(0.62_0.16_320)]">
<span class="text-[30px] font-extrabold leading-[1.02] tracking-[-0.02em] text-thb28-posterink">MOTION<br>& SPACE</span>
</div>
<div class="h-2.75 w-[74%] rounded-full bg-thb28-line"></div>
<div class="h-2.75 w-[58%] rounded-full bg-thb28-line"></div>
</div>
</div>
<button type="button" tabindex="-1" aria-hidden="true" class="absolute inset-y-0 left-0 right-[56%] z-5 hidden border-0 bg-transparent cursor-e-resize group-data-open:block"></button>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-thb28-bg: #2a1f33;
--color-thb28-bg: oklch(0.17 0.03 310);
--color-thb28-edge: #47394f;
--color-thb28-edge: oklch(0.3 0.04 310);
--color-thb28-page: #f5f2f6;
--color-thb28-page: oklch(0.96 0.006 310);
--color-thb28-pageedge: #ded8e0;
--color-thb28-pageedge: oklch(0.88 0.01 310);
--color-thb28-line: #e3dde5;
--color-thb28-line: oklch(0.89 0.008 310);
--color-thb28-ink: #eee9f0;
--color-thb28-ink: oklch(0.94 0.01 320);
--color-thb28-ink2: #38304a;
--color-thb28-ink2: oklch(0.24 0.02 310);
--color-thb28-menuink: #eee9f0;
--color-thb28-menuink: oklch(0.94 0.01 320);
--color-thb28-menudim: #9a8fa4;
--color-thb28-menudim: oklch(0.62 0.03 320);
--color-thb28-posterink: #2f1c33;
--color-thb28-posterink: oklch(0.2 0.05 320);
--color-thb28-acc: #e069b4;
--color-thb28-acc: oklch(0.75 0.17 340);
--font-thb28-sans: 'Space Grotesk', system-ui, sans-serif;
--font-thb28-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-thb28: cubic-bezier(0.4, 0, 0.2, 1);
--ease-thb28in: cubic-bezier(0.2, 0.7, 0.2, 1);
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh. It scopes the demo, and it holds the stage at full height in the
moment before the browser build has compiled its utilities. */
.thb-28 {
width: 100%;
min-height: 100svh;
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.thb-28 > .group').forEach(function (vp) {
if (vp.dataset.bound) return; vp.dataset.bound = '1';
var btn = vp.querySelector('button[aria-controls="thb28-menu"]'),
canvas = vp.querySelector('#thb28-canvas'),
catcher = vp.querySelector('button[aria-hidden="true"]');
function set(open) {
vp.toggleAttribute('data-open', open);
btn.setAttribute('aria-expanded', String(open));
if (open) { canvas.setAttribute('inert', ''); vp.querySelector('#thb28-menu a').focus(); }
else { canvas.removeAttribute('inert'); btn.focus(); }
}
btn.addEventListener('click', function () { set(true); });
catcher.addEventListener('click', function () { set(false); });
vp.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && vp.hasAttribute('data-open')) set(false);
});
});
})();
```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 3D Flip Canvas Mobile Hamburger Navigation
Source: https://codefronts.com/navigation/tailwind-hamburger-menus/tailwind-3d-flip-canvas-mobile-hamburger-navigation/
The theatrical finale: the entire page tilts back in 3D — rotateY around its right edge with real perspective — swinging aside like a door to reveal the nav standing behind it, whose links counter-rotate into place. Three properties produce the drama, and in utilities all three are arbitrary on purpose: perspective, transform-origin and a composed transform are scene geometry, not design tokens.
## HTML
```html
<section class="thb-28 w-full min-h-svh grid justify-center content-start bg-thb28-bg font-thb28-sans text-thb28-ink px-5 py-9" aria-label="3D flip canvas navigation demo">
<div class="group relative w-[min(400px,88vw)] h-135 rounded-3xl overflow-hidden border border-thb28-edge bg-thb28-bg bg-[radial-gradient(130%_100%_at_90%_50%,oklch(0.25_0.06_320/0.85),transparent_60%)] [perspective:1400px]">
<nav id="thb28-menu" aria-label="Site" class="absolute inset-y-0 right-0 left-[44%] flex flex-col gap-0.5 px-6.5 py-8.5 text-thb28-menuink opacity-35 [transform:rotateY(18deg)_translateX(40px)] [transition:transform_.6s_var(--ease-thb28),opacity_.5s] group-data-open:opacity-100 group-data-open:[transform:rotateY(0)_translateX(0)] motion-reduce:[transform:none] motion-reduce:[transition:opacity_.15s]">
<b style="--i:0" class="mb-4 font-thb28-mono text-xs font-bold tracking-[0.26em] uppercase text-thb28-acc">Menu</b>
<a href="#work" style="--i:1" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Selected work</a>
<a href="#reel" style="--i:2" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Show reel</a>
<a href="#process" style="--i:3" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Process</a>
<a href="#studio" style="--i:4" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Studio</a>
<a href="#contact" style="--i:5" class="py-3 text-[21px] font-bold tracking-[-0.01em] text-thb28-menuink no-underline border-b border-[oklch(1_0_0/0.1)] opacity-0 translate-x-4.5 transition-[opacity,translate,color] duration-450 ease-thb28in hover:text-thb28-acc focus-visible:text-thb28-acc focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-4 focus-visible:rounded group-data-open:opacity-100 group-data-open:translate-x-0 group-data-open:[transition-delay:calc(150ms+var(--i)*50ms)] motion-reduce:translate-x-0 motion-reduce:duration-150">Contact</a>
<span style="--i:6" class="mt-auto font-thb28-mono text-[11.5px] text-thb28-menudim opacity-0 transition-[opacity] duration-400 group-data-open:opacity-100 group-data-open:delay-450">© 2026 — Antwerp</span>
</nav>
<div id="thb28-canvas" class="absolute inset-0 z-1 flex flex-col bg-thb28-page [transform-origin:88%_50%] [transition:transform_.6s_var(--ease-thb28),box-shadow_.6s,border-radius_.6s] group-data-open:[transform:translateX(-58%)_rotateY(34deg)_scale(0.92)] group-data-open:rounded-[18px] group-data-open:shadow-[40px_30px_80px_-30px_oklch(0.08_0.03_310/0.8)] motion-reduce:group-data-open:[transform:translateX(-58%)] motion-reduce:[transition:transform_.15s]">
<header class="flex items-center justify-between h-17 pl-6 pr-4.5 border-b border-thb28-pageedge">
<span class="text-[13px] font-extrabold tracking-[0.24em]">DEPTH.WORKS</span>
<button type="button" aria-expanded="false" aria-controls="thb28-menu" aria-label="Open menu" class="grid place-items-center w-11.5 h-11.5 rounded-xl border-0 bg-thb28-ink2 text-thb28-page cursor-pointer transition-[background,scale] duration-250 hover:scale-105 active:scale-93 focus-visible:outline-2 focus-visible:outline-thb28-acc focus-visible:outline-offset-3 motion-reduce:transition-none">
<span class="relative block w-4.5 h-3" aria-hidden="true">
<i class="absolute left-0 top-0 w-4.5 h-0.5 rounded-sm bg-current transition-[translate,rotate] duration-300 group-data-open:translate-y-[5px] group-data-open:rotate-45 motion-reduce:transition-none"></i>
<i class="absolute left-0 top-[5px] w-4.5 h-0.5 rounded-sm bg-current transition-[opacity] duration-200 group-data-open:opacity-0 motion-reduce:transition-none"></i>
<i class="absolute left-0 top-2.5 w-4.5 h-0.5 rounded-sm bg-current transition-[translate,rotate] duration-300 group-data-open:-translate-y-[5px] group-data-open:-rotate-45 motion-reduce:transition-none"></i>
</span>
</button>
</header>
<div class="flex-1 grid content-start gap-3.5 p-5.5" aria-hidden="true">
<div class="grid content-end h-62.5 p-5 rounded-2xl bg-linear-[155deg,oklch(0.85_0.06_330),oklch(0.62_0.16_320)]">
<span class="text-[30px] font-extrabold leading-[1.02] tracking-[-0.02em] text-thb28-posterink">MOTION<br>& SPACE</span>
</div>
<div class="h-2.75 w-[74%] rounded-full bg-thb28-line"></div>
<div class="h-2.75 w-[58%] rounded-full bg-thb28-line"></div>
</div>
</div>
<button type="button" tabindex="-1" aria-hidden="true" class="absolute inset-y-0 left-0 right-[56%] z-5 hidden border-0 bg-transparent cursor-e-resize group-data-open:block"></button>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-thb28-bg: #2a1f33;
--color-thb28-bg: oklch(0.17 0.03 310);
--color-thb28-edge: #47394f;
--color-thb28-edge: oklch(0.3 0.04 310);
--color-thb28-page: #f5f2f6;
--color-thb28-page: oklch(0.96 0.006 310);
--color-thb28-pageedge: #ded8e0;
--color-thb28-pageedge: oklch(0.88 0.01 310);
--color-thb28-line: #e3dde5;
--color-thb28-line: oklch(0.89 0.008 310);
--color-thb28-ink: #eee9f0;
--color-thb28-ink: oklch(0.94 0.01 320);
--color-thb28-ink2: #38304a;
--color-thb28-ink2: oklch(0.24 0.02 310);
--color-thb28-menuink: #eee9f0;
--color-thb28-menuink: oklch(0.94 0.01 320);
--color-thb28-menudim: #9a8fa4;
--color-thb28-menudim: oklch(0.62 0.03 320);
--color-thb28-posterink: #2f1c33;
--color-thb28-posterink: oklch(0.2 0.05 320);
--color-thb28-acc: #e069b4;
--color-thb28-acc: oklch(0.75 0.17 340);
--font-thb28-sans: 'Space Grotesk', system-ui, sans-serif;
--font-thb28-mono: 'JetBrains Mono', ui-monospace, monospace;
--ease-thb28: cubic-bezier(0.4, 0, 0.2, 1);
--ease-thb28in: cubic-bezier(0.2, 0.7, 0.2, 1);
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's
w-full min-h-svh. It scopes the demo, and it holds the stage at full height in the
moment before the browser build has compiled its utilities. */
.thb-28 {
width: 100%;
min-height: 100svh;
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.thb-28 > .group').forEach(function (vp) {
if (vp.dataset.bound) return; vp.dataset.bound = '1';
var btn = vp.querySelector('button[aria-controls="thb28-menu"]'),
canvas = vp.querySelector('#thb28-canvas'),
catcher = vp.querySelector('button[aria-hidden="true"]');
function set(open) {
vp.toggleAttribute('data-open', open);
btn.setAttribute('aria-expanded', String(open));
if (open) { canvas.setAttribute('inert', ''); vp.querySelector('#thb28-menu a').focus(); }
else { canvas.removeAttribute('inert'); btn.focus(); }
}
btn.addEventListener('click', function () { set(true); });
catcher.addEventListener('click', function () { set(false); });
vp.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && vp.hasAttribute('data-open')) set(false);
});
});
})();
```How this works
3D needs a camera before anything rotates.
<div class="group relative overflow-hidden [perspective:1400px]">
<nav class="absolute inset-y-0 right-0 left-[44%]
[transform:rotateY(18deg)_translateX(40px)] opacity-35
group-data-open:[transform:rotateY(0)_translateX(0)]">…</nav>
<div class="absolute inset-0 z-1 [transform-origin:88%_50%]
group-data-open:[transform:translateX(-58%)_rotateY(34deg)_scale(0.92)]">…</div>
</div>Why one composed transform, not rotate-y-34 translate-x-*. Tailwind's separate utilities compile to individual transform properties applied in a fixed order — which would work — but the hinge is a tuned relationship between the translate and the rotate. Writing them in one arbitrary transform keeps the coupling explicit, so nobody adjusts one and wonders why the door stopped hinging.
The off-centre origin is the trick. At 88% the right edge nearly stays put while the left edge sweeps toward the viewer: a door, not a spin.
Discipline from demo 08 still governs. The tilted canvas gets inert, a click-catcher covers the exposed area for tap-to-close, and Escape restores. The catcher is a sibling of the canvas, because an inert subtree swallows pointer events — nesting it would silently kill closing.
Make it yours
- Drama dial:
[perspective:1400px]is subtle,900pxtheatrical,600pxfunhouse. Tune it with therotateYtogether — a big angle plus short perspective clips the canvas off-stage. - Hinge side: mirror the three signs in the composed transform (and move the menu and catcher) for a right swing. Top-hinged is
[transform-origin:50%_0]withrotateX. - The corridor angle (18deg) on the menu is independent of the canvas move — flatten it to 0 for a calmer backdrop, or deepen it for maximum parallax.
- Content can layer in 3D too: add
[transform-style:preserve-3d]to the canvas and[transform:translateZ(30px)]to a hero card. - Retheme through
--color-thb28-acc, which drives the menu's kicker, its hover colour and every focus ring.
Gotchas — read before shipping
[perspective:…]goes on the PARENT. Put it on the canvas (or use theperspective()function inside its own transform) and each element computes its own vanishing point, killing the shared scene.overflow-hiddenclips the swinging edge as it comes toward the viewer — intentional on a stage. On a full page useoverflow-clipon the root or you grow scrollbars mid-swing.- Text on a rotated plane rasterises in some engines mid-transition. Keep body copy off the moving canvas, or accept a 600ms shimmer.
- A transformed canvas is a containing block:
fixedchildren (toasts, FABs) ride the rotation. Portal them outside — the same trap as demos 04 and 08, at triple the visibility. - The click-catcher must sit outside the
inertcanvas, and it must cover the EXPOSED side (here the left), not the menu — otherwise it eats the menu's own link clicks. - Do not split the composed transform into separate utilities without re-tuning: the translate and the rotate are a matched pair, and the individual-property order is not the same composition.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
CSS 3D transforms and perspective have been universal since 2013; inert (Chrome 102, Safari 15.5, Firefox 112) is the newest dependency. The floor quoted here is Tailwind v4's own generated sheet.