Skip to content

Instantly share code, notes, and snippets.

@Xeven777
Created May 15, 2026 09:38
Show Gist options
  • Select an option

  • Save Xeven777/4efadf203a5bc9837525f7a7d2cfcaaf to your computer and use it in GitHub Desktop.

Select an option

Save Xeven777/4efadf203a5bc9837525f7a7d2cfcaaf to your computer and use it in GitHub Desktop.
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 propor…
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.

Awwwards-Level Fluid Scaling

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.


Why this works (the one CSS fact behind it)

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.


How it's implemented in this repo (Tailwind v4)

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.

The mechanic

Tailwind v4 generates utilities using CSS custom properties:

  • p-4padding: calc(var(--spacing) * 4)
  • text-smfont-size: var(--text-sm)
  • gap-8gap: calc(var(--spacing) * 8)
  • w-32, h-16, m-6 — all driven by var(--spacing)

Overriding those variables in a .stage scope makes every standard utility fluid inside that wrapper, with zero new utility names needed.

The two zones

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>

globals.css — the full fluid system

: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;
  }
}

Token reference

@theme extras (available everywhere, not scoped to .stage)

Utility Design px @ 1440 Scales to @ 3840
text-display 96px ~256px
text-hero 280px ~747px
p-section / px-section / gap-section 80px ~213px

.stage — what every standard token becomes

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.

Adding new tokens

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);
}

fluidPx() — raw inline calc for one-off values

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)" }} />

shadcn / base-ui integration

The rules

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>
    </>
  )
}

Portals are always safe

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.


Design width and the --dw variable

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


The consistent-ceiling rule (why it works at every zoom)

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.


Mobile is a separate problem

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; }
}

Common pitfalls

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


The full animation stack

Fluid scaling alone won't get you Site of the Day. The standard stack:

  • GSAP — animation runtime. ScrollTrigger for 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/drei for 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)

File structure in this repo

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

Quick checklist before shipping

  • 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-dvh used everywhere, not min-h-screen / 100vh
  • Display type has explicit leading-none or 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment