ScrollVars / fx
docs · demo · bench · llms.txt · GitHub

Wall to floor fold

Copy runs down a wall and, at the fold, keeps going onto the floor toward you. A clone in perspective, --sv-pin driving both.

Use it for: Manifestos, case-study openers, one long passage that deserves the corner.
Knobs: data-sv-pin (total scroll length) and --sv-fold-travel (pin length minus 100vh, keep them matched); --sv-fold-depth, --sv-fold-eye, --sv-fold-angle, --sv-fold-line (perspective)

Field notes on brand building

Every page is a place.

We build brands the way architects build rooms: with a wall to read, a floor to walk on, and a corner where one becomes the other.

01 / Listening

Strategy starts with listening. Before a single mark is drawn we spend weeks with the people who make the product, the people who sell it and the people who choose it over something else.

Research is not a phase at the start. It is a habit that keeps the work honest: every assumption gets a date, a source and a way to be proven wrong.

02 / Identity

Identity is the grammar that follows. Type, colour, motion and voice are designed as one system, so a banner and a building speak the same language.

Names are harder than logos. A good one survives being shouted across a room, typed on a phone with one thumb and translated into a language nobody on the team speaks.

Colour is a promise about behaviour. If the accent means action on the website, it cannot mean decoration on the packaging and warning in the app.

The best systems are boring to use and exciting to look at.

03 / Experience

Experience is where it has to work: on a phone at night, on a billboard at noon, in a store with bad light.

Motion is the part people feel before they read. It should explain where things came from and where they are going, then get out of the way.

Accessibility is not a checklist at the end. It is the difference between a brand that speaks to everyone and one that only speaks to the people who built it.

04 / Growth

Launch is not the end of the work. It is the first day of measuring it, and of the second version that measurement asks for.

Growth is the long part: the campaigns, the new markets, the product lines nobody had imagined when the first logo was approved.

A studio earns trust one project at a time. The work has to survive contact with a real audience, a real budget, a real deadline, not just a deck.

Every review is a chance to sharpen the idea, not soften it. The best feedback names the problem, not the fix, and leaves room to find a better one.

05 / Craft

Craft shows in what you cannot see as much as what you can: the grid nobody notices, the fallback that quietly holds when the network drops.

A system outlives any single campaign. Build the parts so the next person, on the next brief, does not have to start from nothing.

Follow the floor.

And when you reach the corner, the story does not stop. It comes toward you.

Install: npx scrollvars add wall-floor-fold
Styles: import 'scrollvars/styles/pin.css'

<div data-sv data-sv-pin="380vh">
  <div class="sv-stage fold-scene relative">
    <div class="fold-wall">
      <article class="fold-copy mx-auto w-[min(720px,100%-40px)]">…your copy…</article>
    </div>
    <div class="fold-line" aria-hidden="true"></div>
    <div class="fold-floor" aria-hidden="true" inert>
      <div class="fold-shift"><article class="fold-copy mx-auto w-[min(720px,100%-40px)]">…your copy again…</article></div>
    </div>
  </div>
</div>
<!-- Use the CSS tab's fold-scene/fold-wall/fold-floor/fold-line/fold-copy
     rules: the geometry (perspective, rotate, the floor's
     wrapper offset) is not expressible as utility classes. Tailwind only styles the
     copy itself. -->
<div data-sv data-sv-pin="380vh">     <!-- the pin helper owns the height: match your copy's length -->
  <div class="sv-stage fold-scene">                    <!-- the sticky viewport (pin.css) plus the perspective -->
    <div class="fold-wall">
      <article class="fold-copy">…your copy…</article>
    </div>
    <div class="fold-line" aria-hidden="true"></div>
    <!-- the same copy again, laid flat toward the viewer: aria-hidden AND
         inert together keep it out of the accessibility tree and the tab
         order, never focusable, never announced -->
    <div class="fold-floor" aria-hidden="true" inert>
      <div class="fold-shift"><article class="fold-copy">…your copy again…</article></div>
    </div>
  </div>
</div>

<script type="module">
import { track } from 'scrollvars'
track(document.querySelector('[data-sv-pin]'))
</script>

/* geometry as custom properties, so a page can tune them without touching
   the rules: depth (perspective), eye (perspective-origin y), fold (where
   the wall ends and the floor starts), travel (total scroll distance the
   copy moves, matched to data-sv-pin minus 100vh). */
.fold-scene { perspective: var(--sv-fold-depth, 650px); perspective-origin: 50% var(--sv-fold-eye, 11%); }
.fold-wall { position: absolute; inset: 0 0 auto 0; height: var(--sv-fold-line, 70vh); overflow: hidden; }
.fold-line { position: absolute; left: 0; right: 0; top: var(--sv-fold-line, 70vh); height: 1px; background: currentColor; opacity: .2; }
/* drawn at twice the size and scaled back down: the text nearest the camera stays sharp */
.fold-floor {
  position: absolute; left: 0; width: 100%; top: var(--sv-fold-line, 70vh); height: 100%; overflow: hidden;
  transform-origin: 50% 0; rotate: x var(--sv-fold-angle, 90deg);
  mask-image: linear-gradient(#000 55%, transparent);   /* fades out before it reaches the camera */
}
/* centered with left: 50% + a -50% translate (relative to the copy's own,
   possibly overflowing, width) rather than margin: auto, which clamps to 0 and
   goes flush-left the moment the floor copy is wider than the floor
   itself (narrow viewports): a symmetric overflow reads as "close to the
   camera", a one-sided one reads as a clipped bug. */
.fold-copy { width: min(720px, 100% - 40px); position: relative; left: 50%;
  translate: -50% calc(var(--sv-pin, 0) * -1 * var(--sv-fold-travel, 280vh)); }
.fold-floor .fold-copy { translate: -50% 0; }
/* the floor copy scrolls on its own wrapper, in the same units as the wall: no
   zoom or scale here, engines disagree on how zoom treats vh (the floor drifted). */
.fold-floor .fold-shift { translate: 0 calc(-1 * (var(--sv-pin, 0) * var(--sv-fold-travel, 280vh) + var(--sv-fold-line, 70vh))); }

/* no JS: the wall reads as a normal column; the floor clone never renders */
html:not(.sv-on) .fold-wall { position: static; height: auto; overflow: visible; }
html:not(.sv-on) .fold-floor,
html:not(.sv-on) .fold-line { display: none; }
html:not(.sv-on) .fold-copy { position: static; left: auto; margin: 0 auto; translate: none; }

/* released later (data-sv-off): the same reset, for a tracker that stops */
[data-sv-off] .fold-wall { position: static; height: auto; overflow: visible; }
[data-sv-off] .fold-floor,
[data-sv-off] .fold-line { display: none; }
[data-sv-off] .fold-copy { position: static; left: auto; margin: 0 auto; translate: none; }

/* the same sheet's reduced-motion override, last so it wins on source order:
   .sv-stage already returns to flow (pin.css); this drops the perspective,
   unclips the wall and hides the floor clone. Flat, no clone, no 3D. */
@media (prefers-reduced-motion: reduce) {
  .fold-scene { perspective: none; }
  .fold-wall { position: static; height: auto; overflow: visible; }
  .fold-floor, .fold-line { display: none; }
  .fold-copy { position: static; left: auto; margin: 0 auto; translate: none; }
}
/* the same under html[data-sv-motion="reduce"], the site's own switch */
:where([data-sv-motion="reduce"]) .fold-scene { perspective: none; }
:where([data-sv-motion="reduce"]) .fold-wall { position: static; height: auto; overflow: visible; }
:where([data-sv-motion="reduce"]) .fold-floor,
:where([data-sv-motion="reduce"]) .fold-line { display: none; }
:where([data-sv-motion="reduce"]) .fold-copy { position: static; left: auto; margin: 0 auto; translate: none; }
// npx scrollvars add wall-floor-fold
import { WallFloorFold } from './components/fx/WallFloorFold'

function Manifesto() {
  return (
    <WallFloorFold>
      <h2>Every page is a place.</h2>
      <p>We build brands the way architects build rooms…</p>
    </WallFloorFold>
  )
}
// pin defaults to '380vh': pass a longer one to match more copy, and keep
// travel (default '280vh') at pin minus 100vh. depth/eye/angle/fold are
// props too, written as --sv-fold-* vars.
// The floor is the same children, rendered a second time and marked
// aria-hidden + inert: it never reaches assistive tech or the tab order.

Engine: npm i scrollvars, 12.9 KB gzip as ESM (this page's fx/sv.js IIFE: 13.1 KB). All effects respect prefers-reduced-motion and render complete without JS.