From Viewport to Integer: CSS Typecasting With tan(atan2())

CSS has been able to read the viewport length for over a decade — 100vw has been around since 2013. But that value is a length, not a number. There's no native way to get the viewport width as an integer that you can feed into a calculation for opacity, rotation, or animation progress.

That limitation isn't quite as absolute as it seems. As Jane Ori first detailed in 2023, the trigonometric functions tan() and atan2() can be combined to effectively typecast a CSS length into an integer. The technique isn't new, but it unlocks a class of "viewport transitions" — effects that respond to screen width in ways that go far beyond simple media queries.

Turning 100vw Into an Integer

The core idea is to pass 100vw through atan2() to convert it to radians, then back through tan() to recover the original value — this time as a number rather than a length.

:root {
  --int-width: tan(atan2(100vw, 1px));
}

That bare approach doesn't work consistently across browsers. To get a reliably cross-browser result, the expression needs more wrapping. The full working version is more cryptic but does the job in all modern browsers:

@property --100vw {
  syntax: "<length>";
  initial-value: 0px;
  inherits: false;
}

:root {
  --100vw: 100vw;
  --int-width: calc(10000 * tan(atan2(var(--100vw), 10000px)));
}

What matters here is the output: the custom property --int-width holds the viewport width as a plain integer.

Normalizing to a 0-to-1 Range

Having the viewport as an integer is useful, but a raw value in the range of roughly 0 to 1600 is awkward to work with. Different CSS properties expect different units, and you almost always want to interpolate from a start value to an end value as the screen grows.

The cleaner intermediate step is to map that integer to a 0-to-1 scale. Let's call it --wideness. Once you have that, converting to any property value becomes a matter of multiplying by the target:

/* If `--wideness is 0.5 */

.element {
  opacity: var(--wideness); /* is 0.5 */
  translate: rotate(calc(wideness(400px, 1200px) * 360deg)); /* is 180deg */
  offset-distance: calc(var(--wideness) * 100%); /* is 50% */
}

Where 0 represents a narrow screen and 1 represents a wide one. You also get to define what "narrow" and "wide" mean. If you want the range to run from 400px to 1200px, you define those boundaries explicitly:

Animation Zone between 400px and 1200px

In CSS, the clamping and scaling looks like this:

:root {
  /* Both bounds are unitless */
  --lower-bound: 400; 
  --upper-bound: 1200;

  --wideness: calc(
    (clamp(var(--lower-bound), var(--int-width), var(--upper-bound)) - var(--lower-bound)) / (var(--upper-bound) - var(--lower-bound))
  );
}

Defining explicit boundaries has a second benefit: it lets you position the transition zone in the middle of the viewport range, so the effect reaches its full extent before the viewport hits an unrealistic minimum or maximum width.

Applying the Value to Layout

With --wideness ready, you can start applying it to real elements. Consider a title that reflows as the viewport narrows. Since CSS has no way to select individual words in a sentence, the markup splits the title into spans:

<h1><span>Resize</span> and <span>enjoy!</span></h1>

The default inline behavior of spans also needs to be changed so the reflow is controlled manually:

h1 {
  position: absolute; /* Keeps the text at the center */
  white-space: nowrap; /* Disables line wrapping */
}

Styling the spans with static values keeps the example legible. The hack itself doesn't depend on those rules, but they're worth including for context:

@property --100vw {
  syntax: "<length>";
  initial-value: 0px;
  inherits: false;
}

:root {
  --100vw: 100vw;
  --int-width: calc(10000 * tan(atan2(var(--100vw), 10000px)));
  --lower-bound: 400;
  --upper-bound: 1200;

  --wideness: calc(
    (clamp(var(--lower-bound), var(--int-width), var(--upper-bound)) - var(--lower-bound)) / (var(--upper-bound) - var(--lower-bound))
  );
}

Direction: The Value You Forgot to Flip

For a viewport transition on text, start by deciding how the title should rearrange at small sizes. In the original example, the first span moves up and to the right while the second moves down and to the left:

h1 {
  span:nth-child(1) {
    display: inline-block; /* So transformations work */
    position: relative;
    bottom: 1.2lh;
    left: 50%;
    transform: translate(-50%);
  }

  span:nth-child(2) {
    display: inline-block; /* So transformations work */
    position: relative;
    bottom: -1.2lh;
    left: -50%;
    transform: translate(50%);
  }
}

Both formulas share the same shape — the only difference is the sign. You can combine them with a single --direction variable that takes 1 or -1:

h1 {
  span {
    display: inline-block;
    position: relative;
    bottom: calc(1.2lh * var(--direction));
    left: calc(50% * var(--direction));
    transform: translate(calc(-50% * var(--direction)));
    }

  span:nth-child(1) {
    --direction: 1;
  }

  span:nth-child(2) {
    --direction: -1;
  }
}

But you can't just multiply the offset by --wideness:

span {
  display: inline-block;
  position: relative;
  bottom: calc(var(--wideness) * 1.2lh * var(--direction));
  left: calc(var(--wideness) * 50% * var(--direction));
  transform: translate(calc(var(--wideness) * -50% * var(--direction)));
}

The result is inverted: the words wrap on wide screens and unwrap on narrow ones. Earlier examples transitioned to their end state as --wideness climbed from 0 to 1. Here, the end state should arrive as --wideness falls from 1 to 0. Writing the offset as a subtraction fixes the direction, because the subtracted amount grows along with --wideness:

span {
  display: inline-block;
  position: relative;
  bottom: calc((1.2lh - var(--wideness) * 1.2lh) * var(--direction));
  left: calc((50% - var(--wideness) * 50%) * var(--direction));
  transform: translate(calc((-50% - var(--wideness) * -50%) * var(--direction)));
}

With that correction, the text wraps as the screen narrows. But there's another problem: the spans move in straight lines and can overlap at intermediate widths. That makes the transition a gamble — a user with a screen size in that overlap zone sees a broken layout instead of a nice reflow.

Curving Around the Obstacle

The fix is to make the spans travel along a curve, passing around the center word instead of through it. A simple way to get that curve is to make horizontal movement happen twice as fast as vertical movement. Multiply --wideness by 2, but cap the result at 1 so the element doesn't overshoot its final position:

span {
 display: inline-block;
 position: relative;
 bottom: calc((1.2lh - var(--wideness) * 1.2lh) * var(--direction));
 left: calc((50% - min(var(--wideness) * 2, 1) * 50%) * var(--direction));
 transform: translate(calc((-50% - min(var(--wideness) * 2, 1) * -50%) * var(--direction)));
}

That small change produces a visible arc motion that clears the central text:

The technique shown here is one of the more basic applications of viewport typecasting. Having --wideness as a universal 0-to-1 knob makes it feel less like a hack and more like a missing CSS feature. The pattern opens the door to effects that were previously impractical without JavaScript — making it reasonable to expect more "viewport transitions" to appear as developers explore the idea.