Skip to content
animorio
IT
All components

The stamped passport button

Buttons, SVG, HoverGSAPMediumCompressed weight: Demo 12 KBGSAP 28 KBFreeDownload the zip

A travel agency's Book my trip button, with a navy leather passport standing in its round end: on a click the passport opens, a wooden rubber stamp drops from above and thumps a red arrival stamp on the page, then flies off while a little plane crosses the pill and the words turn to Trip booked. Every booking leaves a new stamp, with another city.

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 stamped passport button</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/mona-sans@5.3.0/wdth.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage">
    <!-- the stamped passport button: the booking button of a travel agency. A navy leather passport with gold foil,
         stitching and an e-passport chip stands in the round left end of the pill. A click opens it: the cover swings
         round its spine, a loose page flutters, a wooden rubber stamp drops from above and thumps an arrival stamp on
         the visa page, then flies off while a little plane crosses the pill and the words turn to Trip booked. Every
         new booking leaves another stamp, with another city; a second click closes the passport -->
    <div class="pp" data-pp>
      <button class="pp__btn" type="button" data-btn data-autoclic>
        <span class="pp__fill" aria-hidden="true"><span class="pp__timer" data-timer></span></span>
        <span class="pp__sky" aria-hidden="true">
          <svg class="pp__trail" data-trail preserveAspectRatio="none"><path data-trail-path fill="none" stroke-width="1.2" stroke-linecap="round" stroke-dasharray="2 4"/></svg>
          <svg class="pp__plane" data-plane viewBox="-8 -8 16 16" width="16" height="16"><path d="M7 0 C7 -0.9 6.2 -1.2 5.2 -1.2 L1.8 -1.2 L-1.6 -6.6 L-3.2 -6.6 L-1.4 -1.2 L-4.4 -1.2 L-5.6 -3 L-6.8 -3 L-6 0 L-6.8 3 L-5.6 3 L-4.4 1.2 L-1.4 1.2 L-3.2 6.6 L-1.6 6.6 L1.8 1.2 L5.2 1.2 C6.2 1.2 7 0.9 7 0 Z"/></svg>
        </span>
        <span class="pp__faces">
          <span class="pp__face" data-face="rest">
            <span class="pp__word" data-label-text>Book my trip</span>
            <span class="pp__note" data-note-text>Lisbon · 3 nights</span>
          </span>
          <span class="pp__face pp__face--busy" data-face="busy" aria-hidden="true">
            <span class="pp__shine" data-busy-text>Checking passport…</span>
          </span>
          <span class="pp__face pp__face--done" data-face="done" aria-hidden="true">
            <svg class="pp__tick" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path data-tick d="M3 8.6 L6.6 12 L13 4.4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
            <span class="pp__done" data-done-text>Trip booked</span>
          </span>
        </span>
      </button>

      <svg class="pp__art" viewBox="0 0 112 156" width="129" height="179" aria-hidden="true" data-art>
        <defs>
          <linearGradient id="pp-leather" x1="0" y1="0" x2="1" y2="1">
            <stop offset="0" style="stop-color:var(--leather-light)"/>
            <stop offset="0.45" style="stop-color:var(--leather)"/>
            <stop offset="1" style="stop-color:var(--leather-deep)"/>
          </linearGradient>
          <pattern id="pp-pebble" width="2.6" height="2.6" patternUnits="userSpaceOnUse">
            <circle cx="0.7" cy="0.8" r="0.42" fill="#fff" fill-opacity="0.09"/>
            <circle cx="2" cy="2" r="0.36" fill="#000" fill-opacity="0.18"/>
          </pattern>
          <linearGradient id="pp-gold" x1="0" y1="0" x2="1" y2="1">
            <stop offset="0" style="stop-color:var(--gold-light)"/>
            <stop offset="0.5" style="stop-color:var(--gold)"/>
            <stop offset="1" style="stop-color:var(--gold-deep)"/>
          </linearGradient>
          <linearGradient id="pp-paper" x1="0" y1="0" x2="1" y2="0">
            <stop offset="0" style="stop-color:var(--paper-shade)"/>
            <stop offset="0.14" style="stop-color:var(--paper)"/>
            <stop offset="1" style="stop-color:var(--paper-light)"/>
          </linearGradient>
          <linearGradient id="pp-paper-left" x1="1" y1="0" x2="0" y2="0">
            <stop offset="0" style="stop-color:var(--paper-shade)"/>
            <stop offset="0.14" style="stop-color:var(--endpaper)"/>
            <stop offset="1" style="stop-color:var(--paper-light)"/>
          </linearGradient>
          <linearGradient id="pp-sheen" x1="0" y1="0" x2="1" y2="0">
            <stop offset="0" stop-color="#fff" stop-opacity="0"/>
            <stop offset="0.5" stop-color="#fff" stop-opacity="0.32"/>
            <stop offset="1" stop-color="#fff" stop-opacity="0"/>
          </linearGradient>
          <radialGradient id="pp-knob" cx="0.36" cy="0.3" r="0.75">
            <stop offset="0" style="stop-color:var(--wood-light)"/>
            <stop offset="0.6" style="stop-color:var(--wood)"/>
            <stop offset="1" style="stop-color:var(--wood-deep)"/>
          </radialGradient>
          <linearGradient id="pp-wood" x1="0" y1="0" x2="1" y2="0">
            <stop offset="0" style="stop-color:var(--wood-deep)"/>
            <stop offset="0.35" style="stop-color:var(--wood-light)"/>
            <stop offset="0.7" style="stop-color:var(--wood)"/>
            <stop offset="1" style="stop-color:var(--wood-deep)"/>
          </linearGradient>
          <linearGradient id="pp-metal" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0" stop-color="#f4f6f7"/>
            <stop offset="0.45" stop-color="#c4ccd1"/>
            <stop offset="0.55" stop-color="#8e989f"/>
            <stop offset="1" stop-color="#dfe4e7"/>
          </linearGradient>
          <!-- rubber-stamp ink: the edges wobble a little and the ink misses the paper here and there -->
          <filter id="pp-ink" x="-30%" y="-30%" width="160%" height="160%">
            <feTurbulence type="fractalNoise" baseFrequency="1.4" numOctaves="2" seed="5" result="noise"/>
            <feDisplacementMap in="SourceGraphic" in2="noise" scale="0.9" xChannelSelector="R" yChannelSelector="G" result="wobble"/>
            <feColorMatrix in="noise" type="matrix" values="0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 -2.4 1.86" result="holes"/>
            <feComposite in="wobble" in2="holes" operator="in"/>
          </filter>
          <clipPath id="pp-cover-clip"><rect x="52" y="84" width="44" height="62" rx="3"/></clipPath>
          <clipPath id="pp-page-clip"><rect x="52.4" y="84.8" width="42.8" height="60.4" rx="2"/></clipPath>
        </defs>

        <!-- the soft shadow the passport throws on the pill -->
        <ellipse data-shadow cx="74" cy="148.4" rx="25" ry="2.4" fill="#062a2c" fill-opacity="0.26"/>

        <g data-book>
          <!-- the back cover, peeking out behind the pages -->
          <rect x="52" y="84" width="44" height="62" rx="3" fill="url(#pp-leather)"/>
          <!-- the silk bookmark ribbon, hanging out between the pages -->
          <g data-ribbon>
            <path d="M85.6 138 L85.6 155 L87.1 153.2 L88.6 155 L88.6 138 Z" style="fill:var(--ribbon)"/>
            <path d="M86.3 140 L86.3 153.4" stroke="#fff" stroke-opacity="0.35" stroke-width="0.4" stroke-linecap="round"/>
          </g>
          <!-- the edges of the pages, a little block of paper -->
          <g fill="none" stroke-width="0.35" style="stroke:var(--paper-shade)">
            <path d="M95.7 86 L95.7 144"/><path d="M95.1 85.6 L95.1 144.4"/>
          </g>

          <!-- the next page, seen only while the visa page lifts -->
          <rect x="52.4" y="85.2" width="42.4" height="59.6" rx="2" fill="url(#pp-paper)" stroke="#d9cdb2" stroke-width="0.25"/>
          <g clip-path="url(#pp-page-clip)" data-leaf-lines fill="none" stroke-width="0.25" style="stroke:var(--guilloche)" stroke-opacity="0.45"></g>
          <!-- the visa page, where the stamps land: it lifts after the cover and falls back -->
          <g data-page data-leaf>
            <rect x="52.4" y="84.8" width="42.8" height="60.4" rx="2" fill="url(#pp-paper)"/>
            <g clip-path="url(#pp-page-clip)">
              <g data-guilloche fill="none" stroke-width="0.25" style="stroke:var(--guilloche)" stroke-opacity="0.75"></g>
              <text class="pp__tiny" x="74" y="90.2" text-anchor="middle">VISAS</text>
              <g fill="none" stroke-width="0.25" style="stroke:var(--guilloche)" stroke-opacity="0.6">
                <rect x="55.6" y="92.4" width="36.4" height="24.6" rx="0.8"/>
                <rect x="55.6" y="118.2" width="36.4" height="24.6" rx="0.8"/>
              </g>
              <g data-marks></g>
              <ellipse data-stamp-shadow cx="74" cy="104.6" rx="12" ry="5" fill="#2a1a12" fill-opacity="0"/>
            </g>
            <!-- the gutter: the paper curves into the spine -->
            <rect x="52.4" y="84.8" width="3.6" height="60.4" fill="#5b4a2e" fill-opacity="0.12"/>
            <rect data-shade x="52.4" y="84.8" width="42.8" height="60.4" rx="2" fill="#0b1626" fill-opacity="0"/>
          </g>

          <!-- the front cover: leather outside, endpaper inside; it turns round the spine -->
          <g data-cover>
            <g data-cover-out>
              <rect x="52" y="84" width="44" height="62" rx="3" fill="url(#pp-leather)"/>
              <rect x="52" y="84" width="44" height="62" rx="3" fill="url(#pp-pebble)"/>
              <path d="M53 86.6 Q53 84.6 55 84.6 L93.6 84.6" fill="none" stroke="#fff" stroke-opacity="0.18" stroke-width="0.6" stroke-linecap="round"/>
              <rect x="52" y="84" width="3.2" height="62" fill="#000" fill-opacity="0.24"/>
              <path d="M55.4 84 L55.4 146" stroke="#fff" stroke-opacity="0.07" stroke-width="0.5"/>
              <rect x="56.6" y="86.6" width="37" height="56.8" rx="1.8" fill="none" stroke="url(#pp-gold)" stroke-opacity="0.65" stroke-width="0.42" stroke-dasharray="1.3 0.9"/>
              <g data-foil>
                <text class="pp__foil" x="75" y="95.6" text-anchor="middle" data-cover-title>PASSPORT</text>
                <!-- the crest: a globe between two branches of laurel -->
                <g fill="none" stroke="url(#pp-gold)" stroke-width="0.6">
                  <circle cx="75" cy="113" r="7.6"/>
                  <ellipse cx="75" cy="113" rx="3.6" ry="7.6"/>
                  <path d="M75 105.4 L75 120.6"/>
                  <path d="M68 109.6 Q75 111.4 82 109.6"/><path d="M67.4 113 L82.6 113"/><path d="M68 116.4 Q75 114.6 82 116.4"/>
                  <path d="M65 106.4 Q62.6 113.6 67.4 120.2"/><path d="M85 106.4 Q87.4 113.6 82.6 120.2"/>
                </g>
                <g fill="url(#pp-gold)">
                  <ellipse cx="64.6" cy="109.4" rx="0.9" ry="1.8" transform="rotate(-24 64.6 109.4)"/>
                  <ellipse cx="64" cy="113.4" rx="0.9" ry="1.8" transform="rotate(-6 64 113.4)"/>
                  <ellipse cx="64.8" cy="117.4" rx="0.9" ry="1.8" transform="rotate(18 64.8 117.4)"/>
                  <ellipse cx="85.4" cy="109.4" rx="0.9" ry="1.8" transform="rotate(24 85.4 109.4)"/>
                  <ellipse cx="86" cy="113.4" rx="0.9" ry="1.8" transform="rotate(6 86 113.4)"/>
                  <ellipse cx="85.2" cy="117.4" rx="0.9" ry="1.8" transform="rotate(-18 85.2 117.4)"/>
                </g>
                <text class="pp__foil-small" x="75" y="129" text-anchor="middle">TRAVEL DOCUMENT</text>
                <!-- the e-passport chip mark -->
                <g fill="none" stroke="url(#pp-gold)" stroke-width="0.5">
                  <rect x="70.6" y="133.2" width="8.8" height="5.6" rx="1.4"/>
                  <path d="M73.6 133.2 L73.6 138.8 M76.4 133.2 L76.4 138.8 M70.6 136 L79.4 136"/>
                </g>
              </g>
              <!-- worn corners -->
              <path d="M93.4 84.4 Q95.6 84.4 95.6 86.6" fill="none" stroke="#fff" stroke-opacity="0.3" stroke-width="0.7" stroke-linecap="round"/>
              <path d="M95.6 143.4 Q95.6 145.6 93.4 145.6" fill="none" stroke="#fff" stroke-opacity="0.16" stroke-width="0.7" stroke-linecap="round"/>
              <g clip-path="url(#pp-cover-clip)">
                <rect data-sheen x="30" y="80" width="14" height="70" fill="url(#pp-sheen)" transform="skewX(-18)"/>
              </g>
            </g>
            <!-- the inside of the cover, drawn mirrored: once the cover has turned it reads the right way -->
            <g data-cover-in visibility="hidden">
              <g transform="matrix(-1 0 0 1 148 0)">
                <rect x="52" y="84" width="44" height="62" rx="3" fill="url(#pp-leather)"/>
                <rect x="52.4" y="84.8" width="42.8" height="60.4" rx="2" fill="url(#pp-paper-left)"/>
                <g data-rosette fill="none" stroke-width="0.22" style="stroke:var(--guilloche)" stroke-opacity="0.7"></g>
                <text class="pp__tiny" x="74" y="90.2" text-anchor="middle">TRAVEL DOCUMENT</text>
                <g style="fill:var(--ink-print)" fill-opacity="0.5">
                  <rect x="57" y="114" width="16" height="1" rx="0.5"/><rect x="57" y="117" width="22" height="1" rx="0.5"/>
                  <rect x="57" y="120" width="12" height="1" rx="0.5"/><rect x="76" y="114" width="14" height="1" rx="0.5"/>
                </g>
                <!-- the holder's signature on its line -->
                <path d="M57 127.6 L91 127.6" stroke-width="0.25" style="stroke:var(--ink-print)" stroke-opacity="0.5"/>
                <path d="M58.6 126.6 C60 122.6 61.4 122.4 61.2 125 C61 127 62.6 124 64 123.8 C65.6 123.6 64.4 126.4 66 126 C67.6 125.6 68.6 123.4 70.4 124.2 C72 125 71 126.6 73.4 125.8 C75.4 125.2 77 124.4 79 125.2" fill="none" stroke-width="0.4" style="stroke:var(--ink-pen)" stroke-linecap="round"/>
                <!-- the machine-readable lines at the bottom -->
                <text class="pp__mrz" x="56.4" y="137.4" data-mrz-1>P&lt;WYF&lt;TRAVELLER&lt;&lt;&lt;&lt;&lt;</text>
                <text class="pp__mrz" x="56.4" y="141.6" data-mrz-2>X4821905&lt;7WYF9605&lt;&lt;&lt;</text>
              </g>
            </g>
          </g>
        </g>

        <!-- the rubber stamp: a wooden knob, a brass collar, the block with its city label and the red rubber -->
        <g data-stamp opacity="0">
          <g data-stamp-tilt>
          <g data-stamp-body>
            <ellipse cx="0" cy="-37" rx="6.6" ry="5.8" fill="url(#pp-knob)"/>
            <ellipse cx="-2.2" cy="-39.2" rx="2" ry="1.3" fill="#fff" fill-opacity="0.35"/>
            <path d="M-2.6 -31.6 L2.6 -31.6 L3.6 -19 L-3.6 -19 Z" fill="url(#pp-wood)"/>
            <path d="M-1.2 -30 L-1.6 -20.4" stroke="#fff" stroke-opacity="0.25" stroke-width="0.6" stroke-linecap="round"/>
            <rect x="-5.6" y="-20.2" width="11.2" height="2.8" rx="0.9" fill="url(#pp-metal)"/>
            <rect x="-11" y="-17.6" width="22" height="11.4" rx="1.6" fill="url(#pp-wood)"/>
            <g fill="none" stroke="#3a2210" stroke-opacity="0.28" stroke-width="0.35" stroke-linecap="round">
              <path d="M-10 -15.4 Q-3 -14.6 4 -15.6 T10 -15"/><path d="M-10 -9 Q-2 -8.2 3 -9.4 T10 -8.6"/>
            </g>
            <rect x="-6.4" y="-15" width="12.8" height="5.6" rx="0.6" fill="#fbf4e4"/>
            <text class="pp__stamp-label" x="0" y="-10.9" text-anchor="middle" data-stamp-label>LIS</text>
            <rect x="-11.6" y="-6.4" width="23.2" height="2.6" rx="0.6" fill="url(#pp-metal)"/>
            <rect x="-10.6" y="-3.8" width="21.2" height="3.8" rx="0.8" style="fill:var(--rubber)"/>
            <rect x="-10.6" y="-1" width="21.2" height="1" rx="0.5" style="fill:var(--ink)" fill-opacity="0.85"/>
          </g>
          </g>
        </g>
      </svg>

      <p class="sr" aria-live="polite" data-live></p>
    </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.

44 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 stamped passport button” from Animorio and fit it into my site

What it does

On a white page, a dark teal pill reading Book my trip in cream with Lisbon · 3 nights under it in sand. In its round left end stands, a little crooked, a passport drawn in SVG: navy leather with a pebbled grain, light on its edge and worn corners, gold stitching all round, a red silk ribbon hanging from between the pages, in gold foil the word PASSPORT, a globe between two branches of laurel, TRAVEL DOCUMENT and the chip mark; now and then a golden glint crosses the cover. Under the mouse the cover opens a crack, as if lifted by a thumb. On a click the passport hops and straightens, its ribbon swinging, the cover turns on its spine with a touch of perspective and its shadow passes over the page; a loose page lifts behind it and falls back. Inside the cover are the security rosette, printed lines, the holder's signature and the two machine-readable lines; on the right, the visa page with its fine security waves. A wooden stamp with a glossy knob, a metal collar and the city on its label drops from above, winds up and thumps down: the stamp squashes, the passport and the pill give for an instant, and a red arrival stamp is left on the page, ARRIVED, the city and the date, with slightly wobbly edges, gaps where the ink missed and a few specks around it, turning matt as it dries. The stamp lifts and flies off, a little plane crosses the pill leaving a dotted trail, the band of sand that filled the pill like a timer fades, and the words turn to Trip booked with a tick. Every new booking leaves another stamp, with another city, on another spot of the page; a second click closes the passport.

How it works

The passport in SVG, over the pill

A single SVG over the pill's left end that takes no pointer: the click goes to the real <button> underneath. Inside, in layers: the back cover, the edge of the pages, the visa page, a loose page and the front cover. The leather is a gradient with a pattern of light and dark dots on top for the grain; the foil and the stitching use a gold gradient.

The cover turning on its spine

A number from 0 to 1 becomes scaleX = cos(πp) and skewY = -7·sin(πp) around the spine (svgOrigin): the cover narrows, passes edge-on and opens out the other side with a touch of perspective. When scaleX turns negative the leather face goes off and the inside face comes on, drawn already mirrored with a matrix, so after the turn it reads the right way. A shadow on the page follows sin(πp).

The stamp and the blow

The stamp drops in with back.out, winds up and strikes with power3.in in about a tenth of a second; on contact three there-and-back tweens squash the stamp, the passport (around its feet) and the pill. The stamp's shadow on the page widens and darkens as it comes closer.

Real-looking ink

The visa is SVG text and rectangles with a filter: feTurbulence makes the noise, feDisplacementMap barely moves the edges, and the same noise, passed through a feColorMatrix, punches holes in the ink where the rubber missed. On impact it is full and glossy, then over a second and a half it turns matt; six specks around it pop out with back.out.

The plane over the pill

An SVG plane follows an arc over the words: on every frame its position and angle come from the curve, and the dots of the trail, already placed along the arc, light up as the plane passes them.

Values to change

NameFileDefaultWhat it changes
LABELscript.jsBook my tripThe words at rest.
NOTEscript.jsLisbon · 3 nightsThe small words under them.
BUSYscript.jsChecking passport…The words while the passport is checked.
DONEscript.jsTrip bookedThe words once it is stamped.
CITIESscript.jsLIS,KYO,OSL,CUZ,MRKThe city codes of the stamps, one per booking, in turn.
DATEscript.js24 OCT 2026The date printed under the city.
THUMPscript.js1How hard the stamp comes down: 0.4 gentle, 1.6 heavy.
SPEEDscript.js1How fast the whole thing runs: 0.6 slow, 1.6 quick.

Try them in the Playground

When to use it

For the booking button of a travel agency, an airline, a hotel or a travel app, or for a button that confirms a document, a permit or a registration.

On phones

The component is 330 pixels wide or the screen minus 32, and 156 tall with a 60 pixel pill; at rest the small words sit under the big ones. With no hover, the finger resting on it opens the cover a crack like the mouse.

With reduced motion

No hops, no turning cover, no stamp coming down and no plane: on a click the passport shows itself open in a fade, the visa fades in and the words change by fading.

Pitfalls

  • The inside face of the cover, turned with a negative scaleX, read backwards: it has to be drawn already mirrored with matrix(-1 0 0 1 148 0), so the two mirrors cancel out.
  • At exactly zero scaleX GSAP loses which way the cover is turning: the value stops at ±0.002 and its sign decides which face shows.
  • The stamp's blow is an instant: three there-and-back tweens (stamp, passport, pill) start from the same point of the timeline, otherwise the blow seems to land in pieces.

Libraries

GSAP 3.15.0, Mona Sans (font variabile) 5.3.0.

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

Testing

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