Skip to content
animorio
IT
Back to the library

The keyhole transition

Transitions, SVGGSAP, CSS onlyAdvancedCompressed weight: Demo 16 KBGSAP 28 KBFreeDownload the zip

A period door in a corridor at night, with a brass lock and the key hanging on its hook. On click the key goes into the lock and turns with two clicks, the light of the room comes through the hole and the view dives into it: the keyhole opens until it fills the screen and shows the room behind. The next click comes back out through the keyhole of another door.

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, viewport-fit=cover">
  <title>Through the keyhole</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/fraunces@5.3.0/full.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/instrument-sans@5.3.0/index.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage" data-stage>

    <!-- the corridor: the wall, the floor and a period door with its brass lock and the key on its hook.
         The three doors of the walk share this drawing: data-door picks the colours and the panels -->
    <div class="hall" data-hall>
      <svg class="door" viewBox="0 0 2400 800" preserveAspectRatio="xMidYMid slice" data-door="0" data-door-svg aria-hidden="true">
        <defs>
          <pattern id="paper" width="60" height="60" patternUnits="userSpaceOnUse">
            <rect class="wp-base" width="60" height="60"/>
            <rect class="wp-line" x="0" width="2" height="60"/>
            <path class="wp-motif" d="M30 22l6 8-6 8-6-8z"/>
          </pattern>
          <linearGradient id="brass" x1="0" y1="0" x2="1" y2="1">
            <stop offset="0" stop-color="#f3d38a"/>
            <stop offset="0.45" stop-color="#c79443"/>
            <stop offset="1" stop-color="#7e5622"/>
          </linearGradient>
          <radialGradient id="knob" cx="0.38" cy="0.34" r="0.7">
            <stop offset="0" stop-color="#fff0c2"/>
            <stop offset="0.35" stop-color="#d9a957"/>
            <stop offset="1" stop-color="#6e4a1c"/>
          </radialGradient>
          <radialGradient id="warm" cx="0.5" cy="0.5" r="0.5">
            <stop offset="0" class="warm-a"/>
            <stop offset="1" class="warm-b"/>
          </radialGradient>
          <radialGradient id="vignette" cx="0.5" cy="0.52" r="0.62">
            <stop offset="0.45" stop-color="#000" stop-opacity="0"/>
            <stop offset="1" stop-color="#000" stop-opacity="0.62"/>
          </radialGradient>
          <clipPath id="plaqueClip">
            <rect x="1142" y="173" width="116" height="25" rx="3"/>
          </clipPath>
          <linearGradient id="floorShade" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0" stop-color="#000" stop-opacity="0.45"/>
            <stop offset="1" stop-color="#000" stop-opacity="0"/>
          </linearGradient>
        </defs>

        <rect class="wall" width="2400" height="700" fill="url(#paper)"/>

        <!-- the two wall lights of the corridor -->
        <g class="sconce">
          <ellipse cx="880" cy="300" rx="190" ry="190" fill="url(#warm)" class="sconce__glow"/>
          <ellipse cx="1520" cy="300" rx="190" ry="190" fill="url(#warm)" class="sconce__glow"/>
          <path class="sconce__arm" d="M880 352v-30M868 352h24M1520 352v-30M1508 352h24"/>
          <path class="sconce__shade" d="M858 322l8-46h28l8 46zM1498 322l8-46h28l8 46z"/>
        </g>

        <rect class="skirting" y="664" width="2400" height="36"/>
        <rect class="floor" y="700" width="2400" height="100"/>
        <rect y="700" width="2400" height="60" fill="url(#floorShade)"/>
        <path class="plank" d="M0 724H2400M0 752H2400M0 784H2400"/>

        <!-- the light of the room, under the door -->
        <ellipse cx="1200" cy="712" rx="190" ry="22" fill="url(#warm)" class="underglow"/>

        <!-- the frame round the door, and the crowns of the second and third doors -->
        <g class="v1">
          <path class="frame" d="M1036 140C1036 50 1364 50 1364 140z"/>
          <path class="fan" d="M1064 140C1064 76 1336 76 1336 140z"/>
          <path class="fan__bars" d="M1200 140V82M1200 140L1118 92M1200 140L1282 92M1200 140L1080 116M1200 140L1320 116"/>
        </g>
        <g class="v2">
          <path class="frame" d="M1012 136L1200 70 1388 136z"/>
          <path class="frame-lo" d="M1050 130L1200 84 1350 130z"/>
        </g>
        <rect class="door-shadow" x="1028" y="132" width="344" height="574"/>
        <rect class="frame" x="1036" y="136" width="328" height="564"/>
        <path class="frame-lo" d="M1048 700V148H1352V700"/>

        <!-- the door leaf -->
        <g data-leaf class="leaf-hit">
          <rect class="leaf" x="1070" y="170" width="260" height="530"/>
          <path class="leaf-edge" d="M1070 700V170H1330"/>

          <!-- four panels: the hotel -->
          <g class="v0">
            <rect class="panel" x="1106" y="204" width="80" height="200"/>
            <rect class="panel" x="1214" y="204" width="80" height="200"/>
            <rect class="panel" x="1106" y="476" width="80" height="190"/>
            <rect class="panel" x="1214" y="476" width="80" height="190"/>
            <rect class="panel-in" x="1118" y="216" width="56" height="176"/>
            <rect class="panel-in" x="1226" y="216" width="56" height="176"/>
            <rect class="panel-in" x="1118" y="488" width="56" height="166"/>
            <rect class="panel-in" x="1226" y="488" width="56" height="166"/>
          </g>
          <!-- two long panels with arched heads: the gallery -->
          <g class="v1">
            <path class="panel" d="M1106 404V262C1106 210 1294 210 1294 262V404z"/>
            <path class="panel-in" d="M1120 390V266C1120 226 1280 226 1280 266V390z"/>
            <rect class="panel" x="1106" y="476" width="188" height="190"/>
            <rect class="panel-in" x="1120" y="490" width="160" height="162"/>
          </g>
          <!-- six panels: the winter garden -->
          <g class="v2">
            <rect class="panel" x="1106" y="204" width="80" height="64"/>
            <rect class="panel" x="1214" y="204" width="80" height="64"/>
            <rect class="panel" x="1106" y="284" width="80" height="120"/>
            <rect class="panel" x="1214" y="284" width="80" height="120"/>
            <rect class="panel" x="1106" y="476" width="80" height="190"/>
            <rect class="panel" x="1214" y="476" width="80" height="190"/>
            <rect class="panel-in" x="1116" y="214" width="60" height="44"/>
            <rect class="panel-in" x="1224" y="214" width="60" height="44"/>
            <rect class="panel-in" x="1116" y="294" width="60" height="100"/>
            <rect class="panel-in" x="1224" y="294" width="60" height="100"/>
            <rect class="panel-in" x="1116" y="486" width="60" height="170"/>
            <rect class="panel-in" x="1224" y="486" width="60" height="170"/>
          </g>

          <!-- the brass plate with the room's name -->
          <g class="plaque" data-plaque>
            <rect x="1142" y="173" width="116" height="25" rx="3" fill="url(#brass)"/>
            <rect class="plaque__line" x="1145.5" y="176.5" width="109" height="18" rx="1.5"/>
            <text class="plaque__text" x="1200" y="189.6" text-anchor="middle" data-plaque-text>Blue Hour Suite</text>
            <g clip-path="url(#plaqueClip)"><path class="plaque__glint" d="M1130 173h10l-8 25h-10z" data-glint/></g>
          </g>

          <!-- the light of the room on the door, round the keyhole, once the lock gives -->
          <ellipse cx="1310" cy="470" rx="60" ry="60" fill="url(#warm)" class="spill" data-spill/>

          <!-- the little hook the key hangs from -->
          <g class="hook">
            <circle cx="1240" cy="440" r="4.6" fill="url(#knob)"/>
            <path d="M1240 441v8a5.2 5.2 0 0 0 10.4 0v-3"/>
            <circle cx="1245.2" cy="454" r="0.1" data-hook/>
          </g>

          <!-- the lock: the long backplate, the knob and the keyhole -->
          <g class="lock" data-lock>
            <rect x="1296" y="374" width="28" height="134" rx="14" fill="url(#brass)"/>
            <rect class="lock__line" x="1299.5" y="377.5" width="21" height="127" rx="10.5"/>
            <circle cx="1310" cy="396" r="14" fill="url(#knob)"/>
            <ellipse class="lock__shine" cx="1305" cy="391" rx="4.5" ry="3"/>
            <path class="keyhole" d="M1307.8 474.9A5.5 5.5 0 1 1 1312.2 474.9L1313.8 485H1306.2z"/>
            <circle cx="1310" cy="470" r="0.1" data-keyhole/>
            <path class="ticks" d="M1290 461l-9-4M1289 470h-10M1290 479l-9 4M1330 461l9-4M1331 470h10M1330 479l9 4" pathLength="1" data-ticks/>
          </g>
        </g>

        <rect width="2400" height="800" fill="url(#vignette)" pointer-events="none"/>
      </svg>

      <!-- the name of the place, top left: it changes with the door -->
      <p class="where" data-where>
        <span class="where__place" data-where-place>Hotel Marlowe Vane</span>
        <span class="where__floor" data-where-floor>Third floor, east corridor</span>
      </p>
    </div>

    <!-- the dark inside the lock, while the view goes through it -->
    <div class="veil" data-veil></div>

    <!-- the rooms behind the doors: only the keyhole's shape of them shows, until it opens to the whole screen -->
    <div class="room" data-room>
      <div class="room__inner" data-room-inner>

        <!-- the hotel: a suite at the blue hour -->
        <section class="scene" data-scene="0" aria-label="The Blue Hour Suite, Hotel Marlowe Vane">
          <svg class="scene__art" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
            <defs>
              <linearGradient id="r0sky" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0" stop-color="#18234a"/>
                <stop offset="0.55" stop-color="#3a4d8c"/>
                <stop offset="1" stop-color="#e79a5a"/>
              </linearGradient>
              <radialGradient id="r0lamp" cx="0.5" cy="0.5" r="0.5">
                <stop offset="0" stop-color="#ffd58c" stop-opacity="0.75"/>
                <stop offset="1" stop-color="#ffd58c" stop-opacity="0"/>
              </radialGradient>
              <pattern id="r0paper" width="44" height="44" patternUnits="userSpaceOnUse">
                <rect width="44" height="44" fill="#1e2b50"/>
                <rect width="3" height="44" fill="#24345e"/>
              </pattern>
            </defs>
            <rect width="1600" height="1000" fill="url(#r0paper)"/>
            <!-- the window on the harbour -->
            <path d="M610 520V280a190 190 0 0 1 380 0v240z" fill="url(#r0sky)"/>
            <g class="r-drift">
              <circle cx="872" cy="262" r="40" fill="#f8e8c2"/>
              <circle cx="886" cy="252" r="9" fill="#ead6a6"/>
              <circle cx="858" cy="278" r="6" fill="#ead6a6"/>
            </g>
            <path class="r-pop" d="M700 214l4 12 12 4-12 4-4 12-4-12-12-4 12-4zM760 300l3 8 8 3-8 3-3 8-3-8-8-3 8-3zM668 330l2.5 7 7 2.5-7 2.5-2.5 7-2.5-7-7-2.5 7-2.5z" fill="#f8e8c2"/>
            <path d="M610 520V452h40v-22h26v22h34v-40h18v-16h10v16h22v28h50v-34h30v-18h12v18h20v40h40v-26h34v18h44v62z" fill="#121a36"/>
            <path d="M686 470h8v10h-8zM760 456h8v10h-8zM846 466h8v10h-8zM922 472h8v10h-8z" fill="#f3c46b" class="r-glow"/>
            <path d="M610 520V280a190 190 0 0 1 380 0v240zM800 92v428M610 380h380" fill="none" stroke="#e8dcc2" stroke-width="12"/>
            <rect x="588" y="518" width="424" height="22" fill="#d9caa9"/>
            <!-- the curtains -->
            <path class="r-sway" data-o="560 110" d="M520 110h120c-8 120 18 250 4 360-10 80 10 120 30 150H520z" fill="#8a2e3b"/>
            <path class="r-sway" data-o="560 110" d="M560 110c4 140-10 300 0 510" fill="none" stroke="#6c2230" stroke-width="5"/>
            <path class="r-sway" data-o="1040 110" d="M1080 110H960c8 120-18 250-4 360 10 80-10 120-30 150h154z" fill="#8a2e3b"/>
            <path class="r-sway" data-o="1040 110" d="M1040 110c-4 140 10 300 0 510" fill="none" stroke="#6c2230" stroke-width="5"/>
            <rect x="500" y="96" width="600" height="16" rx="8" fill="#c9a24e"/>
            <!-- a little picture of a boat -->
            <g class="r-swing" data-o="1230 250">
              <path d="M1230 250l-52 46M1230 250l52 46" stroke="#c9a24e" stroke-width="2" fill="none"/>
              <rect x="1166" y="294" width="128" height="104" fill="#c9a24e"/>
              <rect x="1176" y="304" width="108" height="84" fill="#f0e3c6"/>
              <path d="M1196 362h68l-10 14h-48zM1230 314v46l26-4z" fill="#3a4d8c"/>
            </g>
            <!-- the floor and the rug -->
            <rect y="860" width="1600" height="140" fill="#1d1628"/>
            <rect y="846" width="1600" height="16" fill="#2c2340"/>
            <ellipse cx="800" cy="940" rx="420" ry="54" fill="#5b2c42"/>
            <ellipse cx="800" cy="940" rx="384" ry="40" fill="none" stroke="#c9a24e" stroke-width="3" stroke-dasharray="14 10"/>
            <!-- the armchair -->
            <path d="M404 860V690c0-40 30-60 76-60s76 20 76 60v170z" fill="#7b3342"/>
            <rect x="388" y="740" width="184" height="80" rx="26" fill="#93404f"/>
            <path d="M412 820v40M548 820v40" stroke="#2a1a14" stroke-width="10"/>
            <!-- the bed -->
            <path d="M640 720V600c0-50 320-50 320 0v120z" fill="#4b2a46"/>
            <path d="M680 604v100M740 590v114M800 586v118M860 590v114M920 604v100" stroke="#3c2038" stroke-width="3"/>
            <rect x="620" y="690" width="360" height="70" rx="12" fill="#efe6d6"/>
            <rect class="r-pop" x="650" y="650" width="130" height="54" rx="20" fill="#f7f1e6"/>
            <rect class="r-pop" x="820" y="650" width="130" height="54" rx="20" fill="#f7f1e6"/>
            <path d="M606 740h388l14 120H592z" fill="#c4964f"/>
            <path d="M606 740h388l4 30H602z" fill="#d8ad66"/>
            <path d="M640 800h320M630 830h340" stroke="#a87c3c" stroke-width="3"/>
            <!-- the bedside table and the lamp -->
            <rect x="1010" y="740" width="110" height="120" rx="6" fill="#3a2618"/>
            <rect x="1022" y="770" width="86" height="6" rx="3" fill="#c9a24e"/>
            <ellipse class="r-glow" cx="1065" cy="650" rx="250" ry="250" fill="url(#r0lamp)"/>
            <path d="M1050 740h30l-6-14h-18zM1063 726v-70" stroke="#c9a24e" stroke-width="5" fill="#c9a24e"/>
            <path class="r-glow" d="M1028 668l16-58h42l16 58z" fill="#f6cd84"/>
          </svg>
          <div class="scene__text">
            <p class="scene__place">Hotel Marlowe Vane</p>
            <h2 class="scene__title" data-split>The Blue Hour Suite</h2>
            <p class="scene__line">Third floor, a window on the old harbour</p>
          </div>
        </section>

        <!-- the exhibition: weathers under glass -->
        <section class="scene" data-scene="1" aria-label="The Cabinet of Small Weathers, The Ferrow Gallery">
          <svg class="scene__art" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
            <defs>
              <linearGradient id="r1cone" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0" stop-color="#fffaf0" stop-opacity="0.9"/>
                <stop offset="1" stop-color="#fffaf0" stop-opacity="0"/>
              </linearGradient>
              <pattern id="r1floor" width="80" height="40" patternUnits="userSpaceOnUse">
                <rect width="80" height="40" fill="#b27d4d"/>
                <path d="M0 40L40 0M40 40L80 0" stroke="#9c6a3e" stroke-width="3"/>
              </pattern>
            </defs>
            <rect width="1600" height="1000" fill="#e9e2d4"/>
            <rect width="1600" height="84" fill="#d8cfbd"/>
            <path d="M0 140H1600" stroke="#d3c9b5" stroke-width="4"/>
            <!-- the spotlights -->
            <path class="r-glow" d="M790 84h20l110 480H680zM1170 84h20l130 560H1040z" fill="url(#r1cone)"/>
            <path d="M792 84h16v18h-16zM1172 84h16v18h-16z" fill="#2c2924"/>
            <!-- the two framed weathers on the walls: a sun and a wind -->
            <g class="r-swing" data-o="420 290">
              <path d="M420 290l-90 38M420 290l90 38" stroke="#8b8172" stroke-width="2" fill="none"/>
              <rect x="300" y="326" width="240" height="300" fill="#2b2723"/>
              <rect x="316" y="342" width="208" height="268" fill="#f7f3ea"/>
              <circle cx="420" cy="460" r="46" fill="#e2a03f"/>
              <path class="r-draw" pathLength="1" d="M420 392v-26M420 528v26M352 460h-26M488 460h26M372 412l-18-18M468 508l18 18M468 412l18-18M372 508l-18 18" stroke="#e2a03f" stroke-width="6" stroke-linecap="round" fill="none"/>
            </g>
            <g class="r-swing" data-o="1180 140">
              <path d="M1180 140l-90 108M1180 140l90 108" stroke="#8b8172" stroke-width="2" fill="none"/>
              <rect x="1060" y="246" width="240" height="300" fill="#2b2723"/>
              <rect x="1076" y="262" width="208" height="268" fill="#f7f3ea"/>
              <path class="r-draw" pathLength="1" d="M1110 350c40-30 90 30 140 0M1110 400c50-30 100 30 150 0M1110 450c40-30 80 30 120 0" stroke="#4b6f8c" stroke-width="7" stroke-linecap="round" fill="none"/>
            </g>
            <path d="M370 652h100M370 666h70M1130 572h100M1130 586h70" stroke="#a79d8c" stroke-width="5"/>
            <!-- the floor -->
            <rect y="800" width="1600" height="22" fill="#d5cbb6"/>
            <rect y="822" width="1600" height="178" fill="url(#r1floor)"/>
            <!-- the plinth and the bell jar with its little cloud -->
            <ellipse cx="800" cy="826" rx="150" ry="16" fill="#000" opacity="0.18"/>
            <rect x="700" y="566" width="200" height="260" fill="#f9f7f2"/>
            <rect x="862" y="566" width="38" height="260" fill="#e4dfd3"/>
            <rect x="690" y="556" width="220" height="14" fill="#ffffff"/>
            <ellipse cx="800" cy="552" rx="96" ry="13" fill="#6b4a33"/>
            <g class="r-bob">
              <path class="r-draw" pathLength="1" d="M760 470l-8 30M790 476l-8 30M820 470l-8 30M846 478l-8 30" stroke="#5d8bb0" stroke-width="5" stroke-linecap="round" fill="none"/>
              <path class="r-pop" d="M742 458c-22 0-30-28-10-38-2-24 26-36 44-22 10-22 46-22 54 4 22-6 38 18 26 34 12 14 0 30-16 30z" fill="#ffffff" stroke="#dfe4ea" stroke-width="4"/>
            </g>
            <path d="M716 552V398a84 84 0 0 1 168 0v154" fill="#d9ecf2" fill-opacity="0.3" stroke="#ffffff" stroke-width="4"/>
            <path d="M738 520V404c0-30 12-52 34-64" fill="none" stroke="#ffffff" stroke-width="8" stroke-linecap="round" opacity="0.7"/>
            <circle cx="800" cy="308" r="9" fill="#6b4a33"/>
            <!-- the velvet rope -->
            <path d="M560 700v150M1040 700v150" stroke="#b88a3c" stroke-width="12"/>
            <ellipse cx="560" cy="852" rx="26" ry="8" fill="#b88a3c"/>
            <ellipse cx="1040" cy="852" rx="26" ry="8" fill="#b88a3c"/>
            <circle cx="560" cy="696" r="12" fill="#d8a94f"/>
            <circle cx="1040" cy="696" r="12" fill="#d8a94f"/>
            <path class="r-draw" pathLength="1" d="M560 706C700 800 900 800 1040 706" stroke="#8e1f2b" stroke-width="10" stroke-linecap="round" fill="none"/>
          </svg>
          <div class="scene__text">
            <p class="scene__place">The Ferrow Gallery</p>
            <h2 class="scene__title" data-split>The Cabinet of Small Weathers</h2>
            <p class="scene__line">Tiny climates under glass, until 14 March</p>
          </div>
        </section>

        <!-- the hotel's winter garden -->
        <section class="scene" data-scene="2" aria-label="The Winter Garden, Hotel Ostrander">
          <svg class="scene__art" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
            <defs>
              <linearGradient id="r2glass" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0" stop-color="#cfe2dd"/>
                <stop offset="1" stop-color="#f3efdb"/>
              </linearGradient>
              <pattern id="r2tiles" width="80" height="80" patternUnits="userSpaceOnUse">
                <rect width="80" height="80" fill="#ece3cd"/>
                <path d="M0 0h40v40H0zM40 40h40v40H40z" fill="#2f3d37"/>
              </pattern>
            </defs>
            <rect width="1600" height="800" fill="url(#r2glass)"/>
            <!-- the snowy hills beyond the glass -->
            <path d="M0 690c160-60 280-50 400-20 180-80 340-70 520-10 160-70 340-80 480-10 100-40 160-40 200-30v180H0z" fill="#ffffff" opacity="0.85"/>
            <path d="M0 740c200-50 400-40 600 0 200-40 420-50 600 0 160-30 300-30 400-10v70H0z" fill="#e6efec"/>
            <!-- the iron of the glasshouse -->
            <path d="M0 800V200M400 800V200M800 800V200M1200 800V200M1600 800V200M0 200a200 150 0 0 1 400 0M400 200a200 150 0 0 1 400 0M800 200a200 150 0 0 1 400 0M1200 200a200 150 0 0 1 400 0M0 440H1600M200 800V50M600 800V50M1000 800V50M1400 800V50" stroke="#2f3d37" stroke-width="10" fill="none"/>
            <rect y="800" width="1600" height="200" fill="url(#r2tiles)"/>
            <rect y="796" width="1600" height="10" fill="#2f3d37"/>
            <!-- a fern hanging from the roof -->
            <g class="r-swing" data-o="560 0">
              <path d="M560 0v210" stroke="#2f3d37" stroke-width="3"/>
              <path d="M520 210h80l-12 50h-56z" fill="#b5562f"/>
              <path d="M530 214c-40 30-60 80-62 130M546 214c-14 50-12 90 0 140M574 214c14 50 12 90 0 140M590 214c40 30 60 80 62 130" stroke="#3f8a5c" stroke-width="10" stroke-linecap="round" fill="none"/>
            </g>
            <!-- the palm in its pot -->
            <path d="M330 860h120l-14 90h-92z" fill="#b5562f"/>
            <rect x="322" y="848" width="136" height="22" rx="6" fill="#c76a40"/>
            <g class="r-swing" data-o="390 850">
              <path d="M390 850c-40-120-150-180-250-170 70 20 150 70 250 170z" fill="#2f6b4a"/>
              <path d="M390 850c-10-150 40-260 120-300-30 80-70 170-120 300z" fill="#3f8a5c"/>
              <path d="M390 850c40-110 140-160 220-150-70 30-140 80-220 150z" fill="#2f6b4a"/>
              <path d="M390 850c-60-90-80-200-50-280 10 90 30 180 50 280z" fill="#4c9a69"/>
            </g>
            <!-- the orange tree -->
            <path d="M1150 856h110l-12 94h-86z" fill="#b5562f"/>
            <path d="M1205 856V660" stroke="#5b4030" stroke-width="12"/>
            <g class="r-swing" data-o="1205 760">
              <circle cx="1205" cy="600" r="110" fill="#2f6b4a"/>
              <circle cx="1130" cy="650" r="70" fill="#3f8a5c"/>
              <circle cx="1280" cy="650" r="70" fill="#3f8a5c"/>
              <circle class="r-pop" cx="1160" cy="580" r="16" fill="#f08a24"/>
              <circle class="r-pop" cx="1240" cy="560" r="16" fill="#f08a24"/>
              <circle class="r-pop" cx="1210" cy="640" r="16" fill="#f08a24"/>
              <circle class="r-pop" cx="1120" cy="670" r="14" fill="#f08a24"/>
              <circle class="r-pop" cx="1290" cy="660" r="14" fill="#f08a24"/>
            </g>
            <!-- the table for tea, its chairs, the teapot and the cups -->
            <path d="M640 860V720c0-50 70-50 70 0v140M600 860l40-80M960 860V720c0-50-70-50-70 0v140M1000 860l-40-80" stroke="#2f3d37" stroke-width="6" fill="none"/>
            <path d="M800 732v130M760 870h80" stroke="#2f3d37" stroke-width="10"/>
            <ellipse cx="800" cy="728" rx="150" ry="26" fill="#f6f1e4"/>
            <path d="M650 728c0 30 300 30 300 0" fill="#e9e1cf"/>
            <g class="r-pop">
              <ellipse cx="780" cy="678" rx="46" ry="40" fill="#3b7a8c"/>
              <path d="M820 670c30-6 36-30 46-40" stroke="#3b7a8c" stroke-width="10" stroke-linecap="round" fill="none"/>
              <path d="M738 660c-30-6-30 40 0 36" stroke="#3b7a8c" stroke-width="8" fill="none"/>
              <path d="M760 642h40l-6-12h-28z" fill="#2c5f6d"/>
              <rect x="772" y="620" width="16" height="12" rx="6" fill="#2c5f6d"/>
            </g>
            <path class="r-draw" pathLength="1" d="M868 622c-12-20 12-30 0-52s12-30 0-50M884 626c-10-18 10-26 0-44" stroke="#ffffff" stroke-width="5" stroke-linecap="round" fill="none"/>
            <path class="r-pop" d="M886 700h36l-4 22h-28z" fill="#ffffff"/>
            <path class="r-pop" d="M696 702h32l-4 20h-24z" fill="#ffffff"/>
          </svg>
          <div class="scene__text">
            <p class="scene__place">Hotel Ostrander</p>
            <h2 class="scene__title" data-split>The Winter Garden</h2>
            <p class="scene__line">Tea under glass, from four in the afternoon</p>
          </div>
        </section>

        <!-- the light that comes through the keyhole, before the room is near enough to be seen -->
        <div class="room__light" data-room-light></div>
      </div>
    </div>

    <!-- the soft rim of light round the keyhole while it opens -->
    <svg class="rim" aria-hidden="true" data-rim>
      <path class="rim__wide" data-rim-wide/>
      <path class="rim__thin" data-rim-thin/>
    </svg>

    <!-- the key: hanging on its hook, then in the lock -->
    <div class="key" data-key aria-hidden="true">
      <div class="key__turn" data-key-turn>
      <svg class="key__art" viewBox="0 0 40 100">
        <defs>
          <linearGradient id="keyBrass" x1="0" y1="0" x2="1" y2="0">
            <stop offset="0" stop-color="#8a5f25"/>
            <stop offset="0.35" stop-color="#f0cf85"/>
            <stop offset="0.6" stop-color="#c99847"/>
            <stop offset="1" stop-color="#7a521d"/>
          </linearGradient>
          <path id="keyShape" fill-rule="evenodd" d="M20 1.5a8 8 0 0 1 7.6 10.6 8 8 0 0 1 1.4 15.4H23v2.5h3v4h-3.6V76h9.2v4h-3v4h3v6h-9.2v4.5h-4.8V34h-3.6v-4h3v-2.5H11a8 8 0 0 1 1.4-15.4A8 8 0 0 1 20 1.5zM20 6a3.6 3.6 0 1 0 0 7.2A3.6 3.6 0 0 0 20 6zM13 16.5a3.6 3.6 0 1 0 0 7.2 3.6 3.6 0 0 0 0-7.2zM27 16.5a3.6 3.6 0 1 0 0 7.2 3.6 3.6 0 0 0 0-7.2z"/>
          <clipPath id="keyCut" clipPathUnits="userSpaceOnUse">
            <rect x="-10" y="-10" width="60" height="120" data-key-cut/>
          </clipPath>
        </defs>
        <!-- the part of the key already inside the lock is cut away from below (the script sets the height) -->
        <g clip-path="url(#keyCut)">
          <use href="#keyShape" class="key__shadow" x="1.4" y="2.2"/>
          <use href="#keyShape" fill="url(#keyBrass)"/>
          <path d="M18.2 36v52" stroke="#fff3cf" stroke-width="0.9" opacity="0.7"/>
        </g>
      </svg>
      </div>
    </div>

    <!-- the one command: turn the key, then step back out -->
    <div class="dock" data-dock>
      <button class="act" type="button" data-act data-autoclic>
        <svg class="act__icon" viewBox="0 0 24 24" aria-hidden="true">
          <path d="M8.5 4.5a4 4 0 1 1 0 8 4 4 0 0 1 0-8zM12 9.8l8.5-.1M17 9.7v3.2M19.6 9.7v2.2"/>
        </svg>
        <span class="act__label"><span data-act-label>Turn the key</span></span>
      </button>
    </div>

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

52 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 keyhole transition” from Animorio and fit it into my site

What it does

On a petrol-green wallpaper with stripes and lozenges, between two lit wall lights, an oxblood door with four panels, a cream frame, the brass plate Blue Hour Suite, the long backplate with its knob and the keyhole, through which a warm light shows; under the door, on the floor, the line of light from the room. Top left, Hotel Marlowe Vane and Third floor, east corridor. As it comes in the wall lights flicker on, a glint runs over the plate and the key drops onto its hook and swings until it stops. With the pointer on the door the key sways a little and a glow lights up round the lock. Pressing Turn the key (or the door) the key leaves the hook, flies to the lock tilting, goes into the keyhole (its lower part disappears in the hole) and turns twice: at each click the plate gives a little knock and three short brass lines flash beside it. Then the light of the room comes out of the hole and rims it; the key flies away towards the viewer and the view dives into the lock: the door grows round the hole and slides to the middle, it goes dark like the inside of the lock, and in the black the hole stays, with the room inside it, opening until it fills the screen. Inside, the Blue Hour Suite: the arched window on the harbour at the blue hour with the moon and the roofs, the curtains settling, the little picture of a boat swinging on its nail, the bed, the armchair and the lamp coming on with a flicker; the title rises letter by letter. Step back out goes back through the keyhole, but of another door: the green door of the Ferrow Gallery with the glass fanlight above it, which opens The Cabinet of Small Weathers (a sun and a wind in frames, a cloud raining under a bell jar, the velvet rope), and then the blue six-panel door of Hotel Ostrander, which opens The Winter Garden (the iron and glass house with the snow outside, the hanging fern, the palm, the orange tree, the steaming tea). Then it starts again from the first.

How it works

One number for the dive

The whole dive depends on st.p, from 0 to 1, which a GSAP timeline runs in ZOOM seconds; render() works out the rest from it. The zoom is exponential, z = zFull to the power p, so the speed looks constant from the first instant to the last (doubling is always the same step), and zFull is the zoom at which the circle of the hole covers the corners of the screen too: half the diagonal over the hole's radius in pixels, plus a margin.

The door grows round the hole

The door, an SVG filling the screen with preserveAspectRatio slice, grows by z with its transform-origin on the keyhole (its place on the screen comes from getScreenCTM) and meanwhile slides to the middle, all within z = 6. Between z = 2.2 and z = 8 a black veil rises from 0 to 1: the dark inside the lock; then the door is hidden, because a hundred times larger it is of no use to anyone.

The keyhole-shaped mask

The room sits above the door, cut with a clip-path path() in the shape of the hole: a circle arc wider than half a turn (A with large-arc 1) and the trapezoid slot under it. render() rewrites it every frame at the same place and the same scale as the hole drawn on the door, so they match; at the end the clip-path becomes none. Round it, the same outline in a separate SVG makes the rim of light, which fades as the hole opens.

The key goes in and turns

The key is a div with an SVG inside, above the room. It flies from the hook with two sets of keyframes (up and down, tilted and straight), then sinks into the lock: an SVG clipPath, a rectangle getting shorter, cuts from below the part already inside, in step with the descent. The clicks turn the key on itself by TURN_ANGLE degrees with back.out: the width follows the cosine of the angle and a light skewY gives the depth; at each click the plate moves by a pixel and three short lines draw themselves with stroke-dashoffset.

The depth and the room arriving

In the room, the inner scene starts DEPTH times larger and settles to 1 while the hole opens, and in front of it a warm light fades out between z = 3 and z = 26: from far away the hole shows only light, then the room. Halfway through the dive the room's entrance starts: the title split into letters rises with back.out, pictures and plants swing from their hanging point with svgOrigin and elastic, lights come on with a keyframed flicker, lines draw themselves with pathLength 1.

Values to change

NameFileDefaultWhat it changes
TURNSscript.js2The clicks of the key in the lock before the door gives.
TURN_ANGLEscript.js70How many degrees the key turns at each click.
ZOOMscript.js1.5The seconds of the dive through the keyhole (the way out lasts 85 per cent of it).
DEPTHscript.js1.4How far back the room is at the start of the dive (1 = no depth).
SWINGscript.js1How much the key swings on its hook (0 = it hangs still).
LIGHTscript.js0.85How strong the light of the room through the keyhole is.
KEY_SIZEscript.js1.15The size of the key against the door.

Try them in the Playground

When to use it

For a hotel site that opens its rooms one at a time, for an exhibition that lets you peek at its rooms before going in, for an advent calendar, for the secret page of an event or a launch, for the step from the cover to the content of a story.

On phones

The door is drawn in a very wide SVG with preserveAspectRatio slice, so on a phone held upright it stays whole in the middle and only the sides of the corridor are lost; the dive is the same, with the zoom worked out from the screen's diagonal. The rooms keep the important things in the middle. The key swaying under the pointer is there only with a mouse.

With reduced motion

Nothing moves: the key fades from the hook and reappears in the lock already turned, the light of the keyhole comes on, and the room, already all in place, fades in; on the way out the room fades onto the next door, with the key reappearing on its hook.

Pitfalls

  • The corridor's vignette, a rectangle as large as the whole drawing, sat above the door and took the clicks and the pointer: the door did not open on click and the key did not sway. It has pointer-events none.
  • The key turned with rotationY and a perspective on its box, but the box is moved across the screen with x and y: the perspective stayed at the starting point and the key stretched sideways. With the perspective on the drawing inside, the test Chrome froze halfway through the turn; now the turn is flat: the width follows the cosine of the angle (past 90 degrees you see the other side) and a small skewY gives the depth.
  • The button went blank for a moment on click: the fromTo that brings the new words up, inside a timeline built at once, rendered at once and hid the old words. It needs immediateRender: false.
  • The second click was ignored until the room had finished its entrance (almost five seconds), and the previews' pilot clicks every 2.8: the way back opens as soon as the room fills the screen, while the pictures still swing; the room's entrance is stopped if the room is shown again.
  • The title of the glasshouse, dark green, fell on the dark iron of the glasshouse and could not be read, and the gallery's on the wires of the pictures: behind the titles there is a veil in the room's colour, fading from the top left corner.
  • With the key in the lock its bow covered the knob: the knob moved up the plate, which grew longer.

Libraries

GSAP 3.15.0, Fraunces (font variabile) 5.3.0, Instrument Sans (font variabile) 5.3.0.

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

Testing

Tested on 11 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 animations