Skip to content
animorio
IT
All components

The justified nav

Navigation, Hover, TextGSAP, CSS onlyAdvancedCompressed weight: Demo 9.8 KBGSAP 28 KBFreeDownload the zip

The top bar of a small type foundry, in the minimal style: two hairline rules and a line of links that always fills the same measure, like a justified line. Point at a link and it widens and takes weight on the font's width axis, and the others narrow just enough for the line to stay exactly as long, each on its own spring. The page you are on stays a little wider and a thin vermilion tick comes down over it from the rule. On a phone the links open in a column that keeps its height the same way.

The code

A folder that runs on its own: index.html, style.css, script.js and the libraries from a CDN. The React and Vue tabs hold the same demo as a ready component, which switches its animations off when it leaves the page.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>The justified nav</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/roboto-flex@5.3.0/wdth.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage">
    <!-- the top bar of a small type foundry, Tympan. Its links fill the same measure every time, like a justified line:
         the link under the pointer widens and takes weight on the font's width axis, the others narrow just enough for
         the line to stay exactly as long. The current page stays a little wider and a thin vermilion tick hangs over
         it from the top rule. On a phone the links open in a column that keeps its height the same way -->
    <header class="bar" data-bar data-autoclic>
      <span class="bar__rule bar__rule--top" aria-hidden="true" data-rule></span>
      <span class="bar__rule bar__rule--bottom" aria-hidden="true" data-rule></span>

      <a class="brand" href="#" aria-label="Tympan, home" data-brand><span class="brand__word">Tympan</span></a>

      <button class="toggle" type="button" aria-expanded="false" aria-controls="tympan-links" data-toggle>
        <span class="toggle__label" data-toggle-label>Menu</span>
        <svg class="toggle__icon" viewBox="0 0 20 20" aria-hidden="true">
          <path class="toggle__line" d="M3 7.5h14" data-line/>
          <path class="toggle__line" d="M3 12.5h14" data-line/>
        </svg>
      </button>

      <nav class="nav" id="tympan-links" aria-label="Main" data-nav>
        <span class="nav__end nav__end--start" aria-hidden="true" data-end></span>
        <span class="nav__end nav__end--end" aria-hidden="true" data-end></span>
        <ul class="list" data-list>
          <li class="item"><a class="link" href="#" aria-current="page" data-link><span class="link__mark" aria-hidden="true"><span class="link__tick" data-tick></span></span><span class="link__word" data-word>Typefaces</span></a></li>
          <li class="item"><a class="link" href="#" data-link><span class="link__mark" aria-hidden="true"><span class="link__tick" data-tick></span></span><span class="link__word" data-word>Trials</span></a></li>
          <li class="item"><a class="link" href="#" data-link><span class="link__mark" aria-hidden="true"><span class="link__tick" data-tick></span></span><span class="link__word" data-word>Licensing</span></a></li>
          <li class="item"><a class="link" href="#" data-link><span class="link__mark" aria-hidden="true"><span class="link__tick" data-tick></span></span><span class="link__word" data-word>Custom</span></a></li>
          <li class="item"><a class="link" href="#" data-link><span class="link__mark" aria-hidden="true"><span class="link__tick" data-tick></span></span><span class="link__word" data-word>Journal</span></a></li>
          <li class="item"><a class="link" href="#" data-link><span class="link__mark" aria-hidden="true"><span class="link__tick" data-tick></span></span><span class="link__word" data-word>Studio</span></a></li>
        </ul>
      </nav>
    </header>
  </main>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
  <script src="script.js"></script>
</body>
</html>

Playground

Change values, colours and text with the controls and watch the animation respond live. Or write in the code yourself. Then download the folder with your changes.

23 values to change

Or ask Claude Code for it

With the Animorio connector, Claude Code takes it from here, guide included, and fits it into your project. Paste this request:

Take “The justified nav” from Animorio and fit it into my site

What it does

On a white background a bar 60 pixels tall and 660 wide, made only of two thin black rules, one above and one below. On the left the name Tympan, black and narrow; then six links in Roboto Flex: Typefaces, Trials, Licensing, Custom, Journal and Studio. The first word touches the start of the measure and the last its end, marked by two grey notches standing on the bottom rule, and the spaces between the words are all the same. On arrival the rules are drawn from the left, the name appears and the links open one after the other, from very narrow until they fill the line, going a touch too far and coming back; then a vermilion tick comes down from the top rule over Typefaces, the page you are on. With the mouse on a link it turns black, widens and takes weight; the others all narrow as much as it takes for the line to stay exactly as long, the neighbours first and the far ones a moment later, and the spaces breathe and settle. Moving from link to link, the wide word passes from one to the next. Clicking a link the tick over the old one goes back up into the rule and a new one comes down over the new one, a pixel too long, and settles; the chosen link stays a little wider and fuller than the others. From the keyboard the focused link widens as with the mouse and has a thin frame round it, which widens with it; the arrow keys move from link to link.

How it works

How wide every word is, measured once

Roboto Flex has a width axis from 25 to 151 and a weight axis from 100 to 1000. At start and whenever the sizes change the script gives every word, one setting after another, the values of a grid (six widths by four weights) and reads getBoundingClientRect().width each time: 24 reads in all. Between the points of the grid the width comes from two straight-line interpolations, and nothing is measured while things move.

The justified line: a bisection every frame

Every link has an extra width (HOVER_WIDTH under the pointer, CURRENT_WIDTH if it is the current page) and a weight. Every frame a 28-step bisection looks for the base width b at which the words, each at b plus its own extra, add up to exactly the measure less the spaces (GAP for each space). The list is a flex row with justify-content: space-between, so the first word sits at the start and the last at the end: if the axis reaches the end of its range, the space between the words takes up the rest, as in a line of type.

The springs and the wave from the link

How much a link is under the pointer and how much it is the current page go from 0 to 1 on damped springs (SPRING), with steps of at most 1/120 of a second and never more than 1/30 a frame: the word that widens goes a little too far and comes back. Every word's base also follows the bisection's on a spring of its own, softer the further it is from the link that changed (RIPPLE): the neighbours narrow at once, the far ones a moment later, and the spaces breathe meanwhile. If the springs together made the line too long, a second bisection takes the same width off all of them until the spaces no longer close below the minimum.

A pointer that does not jump

What counts is not the word under the pointer but the middle of the space: the line is cut halfway through every space between two words. A word that widens under the pointer carries its edges with it, so it keeps the pointer until the pointer is past the middle of the next space: no flicking back and forth between two links as the line moves. Off the bar the wide word narrows again; the keyboard focus does the same as the pointer.

The mark of the current page

Every link has a vermilion tick 2 pixels wide hanging from the top rule over the middle of the word: one element for the position (left: 50%, from the CSS) and its child for GSAP, which brings it down with scaleY and a back.out (a pixel too far and back) and takes it up with a power2.in. The tick lives inside the link, so it follows the word while the line justifies itself. The chosen link takes aria-current="page".

The column on a phone

Below 760 pixels the same links go into a panel under the bar, which the Menu button opens with a clip-path coming down. The panel's height never changes (six rows of 46): the row under the finger grows ROW_GROW pixels taller and the others give up the room, the current page keeps a third of it; the row's type grows with it, widens and takes weight, and if it does not fit it first narrows and then gets smaller. A finger sliding down the column moves the tall row; lifting it chooses that link and the panel closes. The tick is the same one on its side: it comes out of the left edge of the row.

Values to change

NameFileDefaultWhat it changes
HOVER_WIDTHscript.js56How much wider the link under the pointer gets, in units of the width axis (which goes from 25 to 151).
HOVER_WEIGHTscript.js720The weight of the link under the pointer (100 to 1000).
CURRENT_WIDTHscript.js10How much wider the page you are on stays, on the width axis.
CURRENT_WEIGHTscript.js540The weight of the page you are on.
REST_WEIGHTscript.js380The weight of the other links.
GAPscript.js38The space the words keep between them, in pixels: the width axis takes up the rest. Bigger, narrower links.
SPRINGscript.js[230, 22]Every link's spring: how stiff, and how much it is held back.
RIPPLEscript.js0.35How much later the links further from the one that changes answer (0, all together).
ROW_GROWscript.js28On a phone, how many pixels taller the row under the finger gets (the others give up the room).

Try them in the Playground

When to use it

For the header of anyone who works with type and words: a type foundry, a graphic design studio, a magazine, a publisher, the portfolio of a typographer or a designer. It suits five, six or seven short links on a light, empty bar; with very long links the axis soon reaches its end and only the spaces work. Colours and weights change in the variables.

On phones

Below 760 pixels the bar is 56 pixels tall and as wide as the screen less 32 (358 at 390): the name on the left, the Menu button with two thin lines on the right. Tapped, the lines turn into a cross, the word says Close and a white panel comes down under the bar with the six links in a column, divided by thin lines: the words come in one after the other, from narrow to their width, and the vermilion tick comes out of the left edge of the current page. The panel always keeps the same height: with a finger on a row it grows taller, its word turns big, wide and full, and the other rows narrow to make room; sliding the finger up and down, the tall row follows it. Lifting the finger chooses that link, the tick comes down on the new one and the panel closes. Esc closes and gives the focus back to the button.

With reduced motion

No springs and no widening words: the link under the pointer only turns black, the current page is already a little wider and the line is already justified. Choosing another link the line is set again in place in a short fade and the vermilion tick fades out on the old one and back in on the new one, without coming down. When the page opens everything fades in; on a phone the panel fades in and out without coming down, and the rows keep the same height.

Pitfalls

  • The first line left the words at rest at width 151, the end of the axis, with spaces a hundred pixels wide: Roboto Flex at 16 pixels is narrower than it looks. The measure is now 660 pixels, the type 17 and the space 38, so at rest the base sits near 96 and the link under the pointer has room to widen.
  • The width of the words does not grow in a straight line with the axis: with the space at 40 the base at rest was 79 (narrow, grey links), at 36 already 107, and the link under the pointer reached the end of the axis. That is why the grid has six points on the width and four on the weight instead of the ends only, and measured on the page the real spaces are 37.92 pixels against the 38 asked for; the little that is left goes into space-between.
  • Picking the link under the pointer as the nearest word, the word that widened moved its middle and the pointer passed to the neighbour, then back: a flicker. Now the edges sit halfway through the spaces and move with the words.
  • With all the springs bouncing together the line got longer than the measure for a moment and the spaces closed up: a second bisection takes the same width off all of them until the spaces stay at least 45% of GAP.
  • The preview's tour and the Enter key both make a click with no detail (detail 0): Enter from the keyboard started the tour. Now an Enter or a Space just pressed on the link chooses it at once.
  • The words in the phone's panel stayed invisible: their entrance starts after a delay, and the frame loop stopped because at that moment no animation was running yet. Now the entrance wakes it at every step.

Libraries

GSAP 3.15.0, Roboto Flex (font variabile, con gli assi della larghezza e del peso) 5.3.0.

Compressed with gzip, the three demo files weigh 9.8 KB; the libraries: GSAP 28 KB.

Testing

Tested on 16 October 2026

  • Desktop: Done
  • Phone: Done
  • Reduced motion: Done
  • Safari on iPhone: To do

License

Use it in your own projects and in your clients’ projects. Do not resell it or redistribute it as is. GSAP stays under its own Standard license. Full license.

All components