Skip to content
animorio
IT
All components

The spirit level tabs

Tabs, Physics, Transitions, SVGGSAPAdvancedCompressed weight: Demo 9.7 KBGSAP 28 KBFreeDownload the zip

The courses of a woodworking school in four tabs, and under the tabs the glass vial of a spirit level: the chosen tab is the one holding the bubble between its two marks. Choosing another, the vial tips, the bubble runs uphill stretching out, brakes squashing up and jiggles between the new marks while the vial levels again; the new text slides down the same slope and comes level with it.

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 spirit level tabs</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/bricolage-grotesque@5.3.0/standard.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage">
    <!-- the courses of a woodworking school, in four tabs. Under the tabs lies the vial of a spirit level: the chosen
         tab is the one whose two marks hold the bubble. Choosing another, the far end of the vial sinks, the bubble
         runs uphill to the new tab, stretching as it goes, and the vial settles level again; the new text slides in
         down the same slope and levels out with it. The vial and the bubble are drawn by the script in the SVG -->
    <div class="level" data-level>
      <div class="level__row">
        <div class="level__tabs" role="tablist" aria-label="Courses" data-tabs>
          <button class="tab" type="button" role="tab" id="tab-joinery" aria-controls="page-joinery" aria-selected="true"><span class="tab__name">Joinery</span></button>
          <button class="tab" type="button" role="tab" id="tab-turning" aria-controls="page-turning" aria-selected="false" tabindex="-1"><span class="tab__name">Turning</span></button>
          <button class="tab" type="button" role="tab" id="tab-carving" aria-controls="page-carving" aria-selected="false" tabindex="-1"><span class="tab__name">Carving</span></button>
          <button class="tab" type="button" role="tab" id="tab-finishing" aria-controls="page-finishing" aria-selected="false" tabindex="-1"><span class="tab__name">Finishing</span></button>
        </div>
        <div class="vial" aria-hidden="true" data-vial>
          <svg class="vial__glass" data-glass>
            <defs>
              <linearGradient id="vial-liquid" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0" style="stop-color: var(--liquid-edge)"/>
                <stop offset="0.1" style="stop-color: var(--liquid-deep)"/>
                <stop offset="0.26" style="stop-color: var(--liquid-light)"/>
                <stop offset="0.4" style="stop-color: var(--liquid)"/>
                <stop offset="0.7" style="stop-color: var(--liquid-deep)"/>
                <stop offset="0.86" style="stop-color: var(--liquid-light)"/>
                <stop offset="0.95" style="stop-color: var(--liquid-deep)"/>
                <stop offset="1" style="stop-color: var(--liquid-edge)"/>
              </linearGradient>
              <linearGradient id="vial-air" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0" style="stop-color: var(--air-shade)"/>
                <stop offset="0.3" style="stop-color: var(--air)"/>
                <stop offset="0.72" style="stop-color: var(--air)"/>
                <stop offset="1" style="stop-color: var(--air-glow)"/>
              </linearGradient>
              <linearGradient id="vial-rim" x1="0" y1="0" x2="1" y2="0">
                <stop offset="0" style="stop-color: var(--rim-dark)"/>
                <stop offset="0.22" style="stop-color: var(--rim)"/>
                <stop offset="0.78" style="stop-color: var(--rim)"/>
                <stop offset="1" style="stop-color: var(--rim-dark)"/>
              </linearGradient>
              <linearGradient id="vial-cap" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0" style="stop-color: var(--brass-dark)"/>
                <stop offset="0.22" style="stop-color: var(--brass-light)"/>
                <stop offset="0.5" style="stop-color: var(--brass)"/>
                <stop offset="1" style="stop-color: var(--brass-dark)"/>
              </linearGradient>
              <radialGradient id="vial-glow" cx="0.5" cy="0.5" r="0.5">
                <stop offset="0" style="stop-color: var(--liquid-light); stop-opacity: 1"/>
                <stop offset="1" style="stop-color: var(--liquid-light); stop-opacity: 0"/>
              </radialGradient>
              <clipPath id="vial-inside"><rect data-inside/></clipPath>
            </defs>
            <rect class="vial__tube" data-tube/>
            <rect class="vial__liquid" fill="url(#vial-liquid)" data-liquid/>
            <g clip-path="url(#vial-inside)">
              <g data-bubble>
                <ellipse class="vial__glow" fill="url(#vial-glow)" data-glow/>
                <path class="vial__air" fill="url(#vial-air)" stroke="url(#vial-rim)" data-air/>
                <path class="vial__shine" data-shine/>
              </g>
            </g>
            <g class="vial__marks" data-marks></g>
            <rect class="vial__gleam" data-gleam/>
            <rect class="vial__cap" fill="url(#vial-cap)" data-cap="0"/>
            <rect class="vial__cap" fill="url(#vial-cap)" data-cap="1"/>
            <line class="vial__ring" data-ring="0"/>
            <line class="vial__ring" data-ring="1"/>
          </svg>
        </div>
      </div>

      <div class="level__pages" data-pages>
        <section class="page" role="tabpanel" id="page-joinery" aria-labelledby="tab-joinery" tabindex="0">
          <h2 class="page__title">Dovetails by hand</h2>
          <p class="page__text">Mark, saw and chop the half-blind dovetails of a small cherry drawer, then glue it up square and take it home.</p>
          <dl class="page__facts">
            <div><dt>Length</dt><dd>Two days</dd></div>
            <div><dt>Group</dt><dd>Six people</dd></div>
            <div><dt>Price</dt><dd>€240</dd></div>
          </dl>
        </section>
        <section class="page" role="tabpanel" id="page-turning" aria-labelledby="tab-turning" tabindex="0" hidden>
          <h2 class="page__title">A bowl from green wood</h2>
          <p class="page__text">Rough out a wet ash log on the lathe, hollow it with a bowl gouge and leave it to dry slowly in a paper bag.</p>
          <dl class="page__facts">
            <div><dt>Length</dt><dd>One day</dd></div>
            <div><dt>Group</dt><dd>Four people</dd></div>
            <div><dt>Price</dt><dd>€150</dd></div>
          </dl>
        </section>
        <section class="page" role="tabpanel" id="page-carving" aria-labelledby="tab-carving" tabindex="0" hidden>
          <h2 class="page__title">Spoons from a birch log</h2>
          <p class="page__text">Split the blank with an axe, shape it on the chopping block and finish the bowl of the spoon with a hook knife.</p>
          <dl class="page__facts">
            <div><dt>Length</dt><dd>One day</dd></div>
            <div><dt>Group</dt><dd>Eight people</dd></div>
            <div><dt>Price</dt><dd>€110</dd></div>
          </dl>
        </section>
        <section class="page" role="tabpanel" id="page-finishing" aria-labelledby="tab-finishing" tabindex="0" hidden>
          <h2 class="page__title">Oil, wax and shellac</h2>
          <p class="page__text">Scrape and burnish a walnut box, then build up a French polish with a cotton rubber until it shines like glass.</p>
          <dl class="page__facts">
            <div><dt>Length</dt><dd>Half a day</dd></div>
            <div><dt>Group</dt><dd>Eight people</dd></div>
            <div><dt>Price</dt><dd>€80</dd></div>
          </dl>
        </section>
      </div>
    </div>
  </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.

60 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 spirit level tabs” from Animorio and fit it into my site

What it does

On a white background, alone, a group of four tabs with the courses of a woodworking school: Joinery, Turning, Carving and Finishing. Under the names runs the glass vial of a spirit level, full of a yellow green liquid, closed by two brass caps, with a pair of thin marks under each tab; between the marks of the chosen tab sits the air bubble, pale with a dark rim, and those two marks are darker than the others. Below are the course title, two lines of text and three facts: the length, the group, the price. When the page opens the level arrives crooked, with the bubble at the far end: it tips the other way and the bubble runs to the first tab, while the text comes in and straightens out. Choosing another tab, the far end of the vial sinks and the bubble, lighter than the liquid, runs uphill to the new tab: as it speeds up it stretches and its tail thins like a drop; as it arrives the vial levels, the bubble brakes, squashes, goes a little past and comes back jiggling between the two marks, which darken. Meanwhile the old text slides off down the slope and the new one comes in from the high side, tipped like the vial, and straightens with it one line after the other; the height in between changes smoothly. With the mouse over a tab the vial tips slightly towards it and the bubble leans out a few pixels; clicking the tab already chosen gives the level a knock and the bubble shivers.

How it works

The vial in SVG

The tabs are four equal columns, each with its name on top and its stretch of the vial below, so the whole column can be tapped. The vial is an SVG over the columns that takes no clicks: the tube, the liquid with a vertical gradient (dark edges, the light the round glass gathers at the top and bottom), a line of reflection, the brass caps and a pair of marks per tab, measured from the width of the columns and redone by a ResizeObserver.

The bubble as a weight on a spring

The bubble has a place and a speed. A dip under the chosen tab pulls it with a spring, but only as hard as the slope allows: the cap is a small base push plus a push in proportion to the sine of the tilt (both times RUSH), so the bubble sets off as the vial tips. The liquid holds it back with a drag in proportion to its speed, much stronger near the marks: that is what decides how far past it goes (WOBBLE). The sums run on the GSAP ticker in steps of 1/240 of a second, with the frame capped at 1/30.

The vial tipping

The tilt is a stiff spring aiming at TILT degrees towards the new tab, a little more for a far one, and back to zero once the bubble is within four tenths of a tab. The vial turns with rotation and drops by half its length times the sine of the angle: in effect it turns about its high end, so nothing rises into the names. The mouse adds a second, small tilt (LEAN), which moves the bubble a few pixels.

The shape of the bubble

The shape has a spring of its own: it wants to be longer the faster the bubble goes (STRETCH), with its area kept, and when the bubble brakes the spring overshoots and squashes it. The outline is a squared-off ellipse of 44 points, worked out on every frame, with its back half narrowing with the speed like a drop; on top is a highlight and below it a paler light in the liquid. A tab's marks darken when the bubble is near.

The text coming level

The old lines leave in 0.13 seconds towards the low side; the new ones come in from the high side with power3.out in TEXT seconds, one after the other. Each line has a spring that follows the tilt of the vial, softer for the lower ones, and turns about its bottom edge: so the text arrives crooked and straightens with the vial. The component is held from the top of the page and the height of the text changes with a tween.

Keys and rest

It is a real tablist: role tab and tabpanel, aria-selected and a single tab reachable with the Tab key; the arrows, Home and End change tab and bring the focus along. When bubble, vial, shape and lines are all still, the loop takes itself off the ticker and draws nothing until something happens. The library's preview clicks the tab after the chosen one, which has data-autoclic.

Values to change

NameFileDefaultWhat it changes
STARTscript.js0The tab chosen when the page opens (0 is the first).
TILTscript.js1.3How far the vial tips towards the new tab, in degrees (a little more for a far one).
RUSHscript.js1How fast the bubble runs through the liquid: more, a quicker bubble.
WOBBLEscript.js0.5How far the bubble overshoots and jiggles when it arrives (0 stops softly, 1 very lively).
STRETCHscript.js0.5How much the bubble stretches as it runs and squashes as it brakes.
LEANscript.js0.35How far the vial tips towards the tab under the mouse, in degrees.
TEXTscript.js0.55The seconds the new text takes to come in.

Try them in the Playground

When to use it

For three, four or five tabs with short names, on a site that has to do with hands and tools: a woodworking school, a tool shop, an architecture studio, a measuring app. Even still, the bubble between its two marks says at once where you are.

On phones

The component is at most 560 pixels wide and on a phone narrows to the screen, with a 16 pixel margin each side: the four columns become 89 pixels, the bubble shortens to 34 and the row of tabs stays 60 tall, name and vial together, all of it tappable. The new text comes in from closer by, so it does not leave the screen. With a finger down on a tab the vial tips towards it as with the mouse over it.

With reduced motion

The vial does not tip and the bubble does not run: it fades out between the old marks and back in between the new ones; the new text fades in, already straight. When the page opens everything is already in place, with no tilt under the mouse and no knock.

Pitfalls

  • Turning about its middle, the tipped vial raised its end near the new tab right into the tail of the g of Finishing: now it also drops by half its length times the sine of the angle, that is it turns about its high end, and nothing ever rises into the names.
  • The first check to avoid redrawing the tilt compared against NaN (Math.abs(x - NaN) > 0.0005 is always false): the vial never tipped. Now the check asks whether the difference is under 0.0005, and redraws when it is not: with NaN it redraws.
  • With the same drag everywhere, the bubble going to a far tab arrived so fast it went 30 pixels past the marks; with a stronger drag only near the marks it runs quickly and overshoots by a few pixels, whatever the distance.
  • The old and new text overlapped for an instant: now each old line leaves in 0.13 seconds and the new one in its place only starts once it has gone.
  • The tiny bubbles shed from the tail, at their real size, looked like black specks of dirt: gone.
  • Tipped by almost three degrees, the text looked crooked by mistake: its tilt is three quarters of the vial's and the springs are stiffer, so it straightens together with it.
  • In SVG gradients stop-color="var(--colour)" as an attribute does not work: the colours sit in the stops' style, so they can be changed from :root.
  • On a phone the text coming in from 40 pixels to the side was cut by the edge of the screen: the shift is now 7% of the width, between 16 and 40 pixels.

Libraries

GSAP 3.15.0, Bricolage Grotesque (font variabile) 5.3.0.

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

Testing

Tested on 5 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