| name | awwwards-fluid-scaling |
|---|---|
| description | Build Awwwards-level websites where the entire layout — text, spacing, images, components — scales smoothly with viewport width and stays visually identical at any browser zoom level (50%, 100%, 120%, 200%). Use this skill whenever building marketing sites, landing pages, portfolios, hero sections, or any "cinematic" frontend where visual proportions must hold across screen sizes from phone to 4K. Fully integrated with Tailwind v4 and shadcn/base-ui — standard utility classes (text-sm, p-8, gap-4, w-32) become fluid inside .stage zones with zero naming conflicts. |
A practical guide for building websites that scale like a single composed image — where everything (typography, spacing, gaps, borders, icons, gallery items) grows and shrinks together as the viewport changes, instead of breaking into discrete media-query steps.
This is the "look" of most Awwwards Site of the Day winners: zoom your browser in and out, and the layout doesn't reflow — it just resizes as a unit, like a vector poster being scaled.
vw units are calculated from the viewport width, and they ignore browser zoom. Browser zoom changes the device pixel ratio, but 100vw is always 100vw.
That means if you size everything in vw:
- The site looks proportionally identical at 50%, 100%, and 200% zoom
- The site scales linearly from a 1024px laptop to a 4K monitor
- No janky breakpoint jumps — everything moves together
Trade-off: zoom no longer helps low-vision users read your text. That's why awwwards-style sites use this technique for cinematic showcase sections (hero, gallery, scroll stories) and keep accessibility-critical UI (forms, dialogs, dashboards) on a normal rem scale.
This repo uses the .stage scope pattern — the cleanest way to integrate fluid scaling with Tailwind v4 and shadcn without parallel class names or naming conflicts.
Tailwind v4 generates utilities using CSS custom properties:
p-4→padding: calc(var(--spacing) * 4)text-sm→font-size: var(--text-sm)gap-8→gap: calc(var(--spacing) * 8)w-32,h-16,m-6— all driven byvar(--spacing)
Overriding those variables in a .stage scope makes every standard utility fluid inside that wrapper, with zero new utility names needed.
| Zone | Class | What lives here | Sizing |
|---|---|---|---|
| Stage | stage |
Hero, galleries, marquees, scroll stories, full-bleed sections | Fluid — standard Tailwind utilities scale with vw |
| OS | os (or no class) |
Forms, dialogs, inputs, dashboards, nav drawers | Fixed rem — shadcn/base-ui defaults, untouched |
// Stage zone — add class="stage" to any full-bleed cinematic wrapper
<section className="stage flex min-h-dvh flex-col px-section py-16 gap-8">
<h1 className="text-hero leading-[0.85] tracking-tight">STUDIO</h1>
<p className="text-base text-muted-foreground">Body copy scales too.</p>
{/* Hero CTA — fine to leave fluid, looks great */}
<Button size="lg">Start project</Button>
{/* Anything that must stay rem-based inside a stage section */}
<div className="os">
<Input placeholder="Email" />
<Button variant="outline">Subscribe</Button>
</div>
</section>
// OS zone — no class needed, everything outside .stage is already rem
<section className="mx-auto max-w-lg px-6 py-24">
<form>
<Input placeholder="Your email" />
<Button>Send</Button>
</form>
</section>:root {
/* For raw inline calc(): calc(DesignPx / var(--dw) * 100vw) */
--dw: 1440;
}
/* Extra @theme tokens for sizes beyond Tailwind's 9xl */
@theme {
--text-display: clamp(2.5rem, 6.667vw, 10.667rem); /* 96px @ 1440 */
--text-hero: clamp(5rem, 19.44vw, 32rem); /* 280px @ 1440 */
--spacing-section: clamp(3rem, 5.555vw, 8.889rem); /* 80px @ 1440 */
}
/* .stage — overrides Tailwind's built-in CSS vars to make the whole
standard utility scale fluid. No new class names, no parallel system.
Min = Tailwind default rem → at ≤1440px (zoom in), values pin at the
exact Tailwind default size. Text never shrinks below readable.
Max at 3840px → ALL tokens cap at the SAME viewport, so the whole
layout scales as one unit. No drift at any zoom level 50%–200%. */
.stage {
--spacing: clamp(0.25rem, 0.2778vw, 0.667rem);
--text-xs: clamp(0.75rem, 0.833vw, 2rem);
--text-sm: clamp(0.875rem, 0.972vw, 2.333rem);
--text-base: clamp(1rem, 1.111vw, 2.667rem);
--text-lg: clamp(1.125rem, 1.25vw, 3rem);
--text-xl: clamp(1.25rem, 1.389vw, 3.333rem);
--text-2xl: clamp(1.5rem, 1.667vw, 4rem);
--text-3xl: clamp(1.875rem, 2.083vw, 5rem);
--text-4xl: clamp(2.25rem, 2.5vw, 6rem);
--text-5xl: clamp(3rem, 3.333vw, 8rem);
--text-6xl: clamp(3.75rem, 4.167vw, 10rem);
--text-7xl: clamp(4.5rem, 5vw, 12rem);
--text-8xl: clamp(6rem, 6.667vw, 16rem);
--text-9xl: clamp(8rem, 8.889vw, 21.333rem);
}
/* .os — restores rem defaults for shadcn inside a .stage */
.os {
--spacing: 0.25rem;
--text-xs: 0.75rem; --text-sm: 0.875rem;
--text-base: 1rem; --text-lg: 1.125rem;
--text-xl: 1.25rem; --text-2xl: 1.5rem;
--text-3xl: 1.875rem; --text-4xl: 2.25rem;
--text-5xl: 3rem; --text-6xl: 3.75rem;
--text-7xl: 4.5rem; --text-8xl: 6rem;
--text-9xl: 8rem;
}
/* On mobile, .stage restores defaults — mobile layouts don't fluid-scale */
@media (max-width: 768px) {
.stage {
--spacing: 0.25rem;
--text-xs: 0.75rem; --text-sm: 0.875rem;
--text-base: 1rem; --text-lg: 1.125rem;
--text-xl: 1.25rem; --text-2xl: 1.5rem;
--text-3xl: 1.875rem; --text-4xl: 2.25rem;
--text-5xl: 3rem; --text-6xl: 3.75rem;
--text-7xl: 4.5rem; --text-8xl: 6rem;
--text-9xl: 8rem;
}
}| Utility | Design px @ 1440 | Scales to @ 3840 |
|---|---|---|
text-display |
96px | ~256px |
text-hero |
280px | ~747px |
p-section / px-section / gap-section |
80px | ~213px |
All tokens share the same ceiling viewport (3840px) — so they all stop scaling at exactly the same moment. No drift.
| Utility | Rem default | Fluid vw midpoint | Caps at 3840px |
|---|---|---|---|
text-xs |
0.75rem | 0.833vw | 2rem |
text-sm |
0.875rem | 0.972vw | 2.333rem |
text-base |
1rem | 1.111vw | 2.667rem |
text-lg |
1.125rem | 1.25vw | 3rem |
text-xl |
1.25rem | 1.389vw | 3.333rem |
text-2xl |
1.5rem | 1.667vw | 4rem |
text-3xl |
1.875rem | 2.083vw | 5rem |
text-4xl |
2.25rem | 2.5vw | 6rem |
text-5xl |
3rem | 3.333vw | 8rem |
text-6xl |
3.75rem | 4.167vw | 10rem |
text-7xl |
4.5rem | 5vw | 12rem |
text-8xl |
6rem | 6.667vw | 16rem |
text-9xl |
8rem | 8.889vw | 21.333rem |
--spacing (all p/m/gap/w/h) |
0.25rem | 0.2778vw | 0.667rem |
Reading the table: below 1440px viewport (zoom in), the utility pins to its rem default. Between 1440–3840px (includes zoom out to ~40%), it scales smoothly. Above 3840px, it caps. So zoom-in always looks exactly like normal Tailwind; zoom-out scales proportionally.
To add a token beyond the standard scale, derive bounds from the same formula so it caps at 3840px:
vw = designPx / 1440 * 100
max = designPx * 3840 / 1440 (= designPx * 2.667)
min = rem equivalent (keeps zoom-in pinned at readable size)
Example — a 200px element at 1440 design:
@theme {
--spacing-banner: clamp(8.333rem, 13.889vw, 22.222rem);
}For values that don't map to a utility (letter-spacing, transform origins, SVG coordinates), use the helper in lib/utils.ts:
import { fluidPx } from "@/lib/utils"
// Returns "calc(2 / 1440 * 100vw)"
<span style={{ letterSpacing: fluidPx(2) }}>SCROLL</span>
// With clamp bounds: "clamp(24px, calc(48 / 1440 * 100vw), 128px)"
<div style={{ borderRadius: fluidPx(48, { min: 24, max: 128 }) }} />You can also use raw CSS with --dw:
<div style={{ gap: "calc(24 / var(--dw) * 100vw)" }} />Dialogs, Sheets, Popovers, Tooltips, Dropdowns → always safe. They portal to <body>, which is outside any .stage. Never affected.
Buttons, Badges inside a Stage hero → leave them. Fluid sizing on a CTA button in a hero looks intentional and great.
Inputs, Forms, Command menus inside a Stage section → wrap with <div className="os">. This restores exact Tailwind rem defaults for everything inside.
Complex shadcn UI → put the whole section outside .stage. A contact form, a settings panel, a data table — these get their own <section> with no stage class.
export default function Page() {
return (
<>
{/* Cinematic hero — full fluid */}
<section className="stage flex min-h-dvh flex-col px-section py-16">
<h1 className="text-hero tracking-tight" style={{ lineHeight: 0.85 }}>
STUDIO
</h1>
<Button size="lg">View work</Button> {/* fluid CTA, looks great */}
</section>
{/* Mixed section — fluid layout, rem shadcn */}
<section className="stage grid grid-cols-2 gap-16 px-section py-24">
<h2 className="text-5xl font-bold">Get in touch</h2> {/* fluid */}
<div className="os flex flex-col gap-4"> {/* rem */}
<Input placeholder="Your email" />
<Textarea placeholder="Message" />
<Button>Send</Button>
</div>
</section>
{/* Pure OS section — no .stage at all */}
<section className="mx-auto max-w-2xl px-6 py-24">
<Command>...</Command>
</section>
</>
)
}Any component that uses Radix UI or Base UI portals (Dialog, Popover, DropdownMenu, Sheet, AlertDialog, Tooltip) renders outside the DOM tree of your page. They land at <body> level, always in rem. Never worry about these.
--dw: 1440 is set at :root for raw calc() usage. Use it when you need an exact design-spec pixel value as a one-off inline style:
// 280px in your Figma at 1440 design width:
<div style={{ fontSize: "calc(280 / var(--dw) * 100vw)" }} />
// Equivalent using fluidPx():
<div style={{ fontSize: fluidPx(280) }} />For mobile, --dw is re-baselined to 390 inside @media (max-width: 768px) — so the same calc() formulas work on phones relative to iPhone width.
This is the #1 reason fluid layouts break when you zoom out below 100%.
If each token's max is hand-picked at a different effective viewport — hero caps at ~2400px equivalent, section spacing at ~2100px, body text at ~1900px — then as you zoom out the tokens hit their ceilings one at a time. Type freezes while spacing keeps growing; the layout drifts out of proportion.
Fix: every clamp() max is derived from the same cap viewport (3840px). The formula for any token whose design value is D px at 1440:
max = D × 3840 / 1440 (= D × 2.667)
Because all tokens cap together, the layout scales as a single unit from 50% to 200% zoom with zero drift. The min floors use the Tailwind default rem value — so zooming in (below 1440px CSS viewport) pins at exactly the normal Tailwind size, never shrinking below readable.
The .stage system resets to Tailwind rem defaults at ≤768px. Below that breakpoint you're in full control — stack columns, switch to a simpler hero layout, hide custom cursors.
// Desktop — fluid hero layout
<section className="stage flex min-h-dvh flex-col justify-between px-section">
// Mobile reset happens via CSS in globals.css — you handle layout with:
<section className="stage flex flex-col gap-8 px-6 py-16
md:flex-row md:min-h-dvh md:px-section">You can also re-baseline --dw for mobile-specific raw calc:
@media (max-width: 768px) {
:root { --dw: 390; }
}Horizontal scroll. html, body { overflow-x: hidden } is already set in globals.css. Don't remove it — any vw-wider element (marquee, rotated card) will leak a scrollbar without it.
Line height. text-hero with no leading-* class will use the Tailwind default line height, which is too large for display type. Always pair with leading-none or an inline style={{ lineHeight: 0.85 }}.
Border and radius. border-2 → 2px, stays fixed — correct. rounded-lg → 0.5rem, stays fixed — correct. Don't fluid-scale borders or radii below 4px; they're below perceptual threshold. If you need a radius that scales, use fluidPx() inline.
Icon sizes. An icon inside .stage at w-6 h-6 becomes fluid. That's usually fine at desktop, but check at extreme zoom. If it must stay fixed: <span className="os"><Icon /></span>.
vh for hero height. iOS Safari's address bar breaks 100vh. Already handled: use min-h-dvh everywhere.
Font loading flash. Add font-display: swap and metric overrides to any custom font:
@font-face {
font-family: 'DisplayFont';
src: url('...') format('woff2');
font-display: swap;
size-adjust: 95%;
ascent-override: 90%;
}max-w-* is safe inside .stage. max-w-md, max-w-2xl etc. use separate CSS variables (--container-*), not --spacing. They stay fixed even inside a stage section.
Fluid scaling alone won't get you Site of the Day. The standard stack:
- GSAP — animation runtime.
ScrollTriggerfor scroll-driven effects. - Lenis (
lenis) — smooth scroll inertia. Free, drop-in. - SplitType — split headlines into chars/words/lines for stagger reveals.
- Three.js / React Three Fiber — WebGL.
@react-three/dreifor helpers. - clip-path animations — geometric reveals. Animate
clip-path: polygon()with GSAP. - mix-blend-mode: difference — custom cursor that inverts over images.
Minimal Lenis + GSAP wiring:
import Lenis from 'lenis'
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
const lenis = new Lenis()
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => lenis.raf(time * 1000))
gsap.ticker.lagSmoothing(0)app/
globals.css ← .stage, .os, @theme extras, --dw var
layout.tsx ← ThemeProvider, font vars, rem world
page.tsx ← composes Stage + OS sections
components/
ui/ ← shadcn/base-ui (untouched, always rem)
stage/ ← cinematic components (use .stage class internally)
hero.tsx
loader.tsx
lib/
utils.ts ← cn(), fluidPx() helper
- Cinematic sections have
className="stage ..." - shadcn forms/inputs inside stage are wrapped with
className="os" -
html, body { overflow-x: hidden }— already in globals.css -
min-h-dvhused everywhere, notmin-h-screen/100vh - Display type has explicit
leading-noneor unitless line-height - Custom cursors hidden on mobile with
@media (max-width: 768px) - Custom fonts have
font-display: swap+ metric overrides - Any new oversized @theme token follows:
max = designPx * 2.667,min = rem equivalent - Tested at 50%, 75%, 100%, 150% browser zoom — proportions hold
- Tested at 1280px, 1440px, 1920px, 2560px viewport widths
- Tested on a real phone (not just devtools)