Skip to content
animorio
IT
Back to the library

Dice on the table

Physics, 3DGSAP, CSS onlyAdvancedCompressed weight: Demo 15 KBGSAP 28 KBFreeDownload the zip

Two real CSS 3D dice thrown from a leather cup: they roll across the cloth with real physics, bounce, knock into each other and into the rail and settle on a face. The sum drops in, big, with a bounce.

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>Dice on the table</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/wdth.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage">
    <!-- the dice night of a made-up board game bar. On the left the name and the roll; on the right a felt table seen
         from above, with a walnut rail, two dice and a leather cup. The dice, their faces and their shadows are built
         by the script -->
    <section class="side">
      <header class="brand">
        <svg class="brand__mark" viewBox="0 0 40 40" aria-hidden="true">
          <rect x="5" y="5" width="30" height="30" rx="8" transform="rotate(-12 20 20)"/>
          <circle cx="14.5" cy="15" r="2.6"/><circle cx="20" cy="20" r="2.6"/><circle cx="25.5" cy="25" r="2.6"/>
        </svg>
        <p class="brand__name">Pip &amp; Felt<span>Board game bar</span></p>
      </header>

      <div class="score" data-score>
        <div class="score__intro" data-intro>
          <p class="score__kicker">Thursday dice night</p>
          <h1 class="score__title">Roll for the house&nbsp;round</h1>
        </div>
        <div class="score__result" data-result aria-hidden="true">
          <p class="score__kicker" data-name>Natural seven</p>
          <p class="score__sum"><span data-sum>7</span></p>
          <p class="score__parts" data-parts>4 + 3</p>
        </div>
      </div>
      <p class="sr" role="status" aria-live="polite" data-said></p>

      <div class="actions">
        <button class="roll" type="button" data-roll data-autoclic>
          <span class="roll__fill" aria-hidden="true"></span>
          <span class="roll__label" data-roll-label>Roll the dice</span>
        </button>
        <p class="rule">House rule: doubles buy the next round.</p>
      </div>
    </section>

    <div class="scene" data-scene>
      <div class="jolt" data-jolt>
        <div class="table" data-table>
          <div class="rail" aria-hidden="true"></div>
          <div class="felt" aria-hidden="true"><span class="felt__print">Pip &amp; Felt</span></div>
          <div class="wall wall--back" aria-hidden="true"></div>
          <div class="wall wall--front" aria-hidden="true"></div>
          <i class="anchor" data-anchor></i>
          <i class="anchor" data-anchor></i>
        </div>
      </div>
      <div class="cup-place" data-place>
        <div class="cup" data-cup>
          <svg viewBox="0 0 120 150" aria-hidden="true">
            <defs>
              <linearGradient id="dt-leather" x1="0" x2="1">
                <stop offset="0" stop-color="var(--leather-dark)"/>
                <stop offset="0.3" stop-color="var(--leather-light)"/>
                <stop offset="0.55" stop-color="var(--leather)"/>
                <stop offset="1" stop-color="var(--leather-dark)"/>
              </linearGradient>
              <linearGradient id="dt-band" x1="0" x2="1">
                <stop offset="0" stop-color="var(--leather-deep)"/>
                <stop offset="0.32" stop-color="var(--leather-dark)"/>
                <stop offset="1" stop-color="var(--leather-deep)"/>
              </linearGradient>
              <radialGradient id="dt-mouth" cx="0.5" cy="0.2" r="0.8">
                <stop offset="0" stop-color="#140b06"/>
                <stop offset="1" stop-color="#3a2012"/>
              </radialGradient>
            </defs>
            <path class="cup__body" d="M6 18 Q8 90 15 140 Q60 152 105 140 Q112 90 114 18 Z" fill="url(#dt-leather)"/>
            <path d="M9 40 Q60 52 111 40 L110.6 52 Q60 64 9.3 52 Z" fill="url(#dt-band)"/>
            <path d="M13.5 118 Q60 130 106.5 118 L105.6 130 Q60 142 14.4 130 Z" fill="url(#dt-band)"/>
            <path class="cup__stitch" d="M10 45 Q60 57 110 45"/>
            <path class="cup__stitch" d="M14.5 123.5 Q60 135.5 105.5 123.5"/>
            <path class="cup__seam" d="M88 24 Q89 80 92 138"/>
            <path class="cup__stitch" d="M85 26 Q86 80 89 136"/>
            <g class="cup__crest">
              <rect x="44" y="72" width="26" height="26" rx="6" transform="rotate(-12 57 85)"/>
              <circle cx="51.5" cy="80" r="2"/><circle cx="57" cy="85" r="2"/><circle cx="62.5" cy="90" r="2"/>
            </g>
            <ellipse cx="60" cy="18" rx="54" ry="12" fill="url(#dt-mouth)"/>
            <path class="cup__rim" d="M6 18 A54 12 0 0 0 114 18 A54 12 0 0 0 6 18 Z"/>
            <path class="cup__shine" d="M24 30 Q26 80 30 128"/>
          </svg>
        </div>
      </div>
      <p class="hint" data-hint>Tap the table to throw</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.

36 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 “Dice on the table” from Animorio and fit it into my site

What it does

The dice night of a made-up board game bar, Pip & Felt. On the left the name, the heading Roll for the house round and the Roll the dice button; on the right a green felt table seen from above, with a walnut rail and padded cushions inside it, and behind the far left corner a leather cup with stitching, two tooled bands and a die pressed into it. When the page opens the table rises from below, the cup drops onto the bar and squashes, and two ivory dice fall on the cloth, bounce a couple of times and stop. On a click (on the button or on the table) the cup comes forward over the cloth, the dice hop into it in an arc and it feels them land with a little squash; then it is rattled, lifts slightly and tips over: the dice leave its mouth, roll across the cloth turning over edges and faces, bounce, knock into each other and into the rail (the table gives a little jolt that way), slow down and settle flat on a face. The cup rights itself with a wobble and goes back to its place. Meanwhile on the left the sum drops in, big, with a bounce and a squash, with the name of the roll (Snake eyes, Natural seven, Boxcars, Hard eight…) and the two numbers. The result is not chosen beforehand: it is the face the physics leaves on top. With doubles the two dice give a little hop of joy and turn a quarter round. Every face gets darker as it turns away from the light and each die has its shadow on the cloth, wider and lighter when it is in the air.

How it works

A die in CSS 3D

Each die is a div with transform-style: preserve-3d and six faces pushed out by half a side with translateZ, turned with rotateX and rotateY. The pips sit in a three by three grid, with an inner shadow that makes them look drilled. The rounded corners would leave holes at the edges: inside the die there are three dark ivory planes, one per axis, which fill them and act as the bevel. The die lives inside the table, tilted with rotateX, so for the die up is the table's z axis.

Rigid body physics

Each die has a position, a speed, an orientation (a quaternion) and a spin. At every step gravity pulls it down; if one of its eight corners goes under the cloth or past the rail, an impulse is born at that corner: along the normal, with the bounce, and sideways, as much as friction allows. Precisely because the impulse is at the corner and not at the centre, the die starts spinning and rolls. A cube has the same inertia round every axis (mass times side squared over six), so the sums stay simple. The two dice hit each other like two balls, and the knock sets them spinning too.

Work it out first, play it after

The whole throw is worked out on the click, at 480 steps a second, and sixty a second are kept: the position and quaternion of each die. Then a GSAP tween on a single number runs through the frames, with a slerp between one and the next. So the throw sits in the same timeline as the cup and the result, the speed is a timeScale, and the knocks against the rail (which the sums noted with their moment) become calls that jolt the table at the right time.

Settling on a face

A die counts as still when it touches the cloth, is slow and its highest face points almost straight up. In the last twelve frames it is turned by the least it takes to lie flat on that face: a quaternion that brings its highest axis onto the z axis. The number is read from there: the axis pointing up gives the face. After three seconds a growing brake stops the dice anyway, even with a slippery cloth and rubber dice.

Light, shadows and the cup

On every frame the normal of each face, turned with the die, is compared with the direction of the light: a face that turns away gets a thicker dark veil. The shadow is a radial gradient on the cloth, pushed away from the light and wider and lighter the higher the die is. The cup is a flat SVG drawing over the scene: GSAP moves its foot in table coordinates, two invisible anchors inside the table say where that lands on screen and how big it is there, and GSAP rattles it and tips it over round its lower corner. Its mouth, worked out again in table units, is the point the dice leave from.

Values to change

NameFileDefaultWhat it changes
THROWscript.js1How hard the cup throws the dice: at 0.5 they stop halfway across the table, at 1.6 they crash into the far rail.
BOUNCEscript.js0.42How much they bounce on the cloth: 0 they thud down, 0.7 they seem made of rubber.
FELTscript.js0.5The grip of the cloth: low the dice slide, high they stop rolling sooner.
SPINscript.js1How much the dice spin as they leave the cup.
TILTscript.js48How much from above the table is seen, in degrees: 30 almost from the side, 62 almost from above.
SPEEDscript.js1The speed of the whole throw: 2 is twice as fast, 0.5 half as fast, a slow motion.

Try them in the Playground

When to use it

For a board game bar or shop, a games site, a draw or a random prize in a promotion, the «surprise me» page of a menu, a party game, the wait for a result that has to feel truly left to chance.

On phones

The same animation with the sizes adapted: the table becomes deeper than wide, below the heading and above the button, the dice stay big enough to read the pips and the throw goes from the far left towards the near right. A tap on the table or on the button is enough.

With reduced motion

Nothing moves: when the page opens the table, the words and the dice fade in. On a click the dice fade away, the throw is still worked out and they reappear already still where the physics left them, with the sum fading in.

Pitfalls

  • CSS has the y axis pointing down and z towards us: a left-handed system. The physics uses the same axes and the same cross product, so it is a mirror world, but mirrored physics is still physics: just don't mix in formulas written for right-handed axes.
  • With a fixed step of 1/60 s the dice went through the rail and trembled when still: there are 480 steps a second, and when a corner only just touches (under 1.2 sides a second) the bounce is zero, so a die at rest does not jitter.
  • A die that stops balanced on an edge gives no number: a die counts as still only if its highest face points almost straight up, and in the last frames it is turned by the little it needs to lie flat.
  • With a slippery cloth and rubber dice the throw could go on forever: after three seconds a growing brake stops everything, so the cup and the sum never wait too long.
  • The cup, drawn on a plane inside the 3D table, was sliced clean off when tipped: Chrome sorts badly a plane that cuts through the others. Now it lives outside the 3D, drawn flat over the scene, and two invisible anchors in the table (read with getBoundingClientRect) tell it where to stand and how big to be.
  • A blur filter on the shadows inside a 3D context made every frame very slow, and an opacity on the whole die flattened it (opacity breaks preserve-3d): the die fades face by face through a CSS variable, and the shadows are just radial gradients with more stops, which look blurred and cost nothing.

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 15 KB; the libraries: GSAP 28 KB.

Testing

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