Pure CSS Products & branding #hover #product #box #packaging

Unboxing in pure CSS

A shut box, seen from a gentle angle. Hover or focus: the lid swings open on its back edge and the product card rises out. One hinge (transform-origin) and two staggered transitions.

  • 170 lines of CSS
  • 13 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Every length is a multiple of one base unit, --u, tied to the canvas, so the box, its label and the card inside are the same share of a gallery card, the editor and a recording canvas. The numbers below are those units.
  2. An open-top box: four walls around the centre and a base laid flat with rotateX(-90deg), pushed down by half the wall height.
  3. The lid stands above the back wall and is hinged on its bottom edge with transform-origin: 50% 100%. At rest rotateX(-90deg) lays it flat over the opening, so the box is shut and the card inside is hidden; open, rotateX(-32deg) lifts it most of the way up, leaning a little towards you.
  4. Two transitions, swapped delays: opening, the lid goes first and the card rises 0.3s later; closing, the card drops first and the lid waits 0.3s. Each state carries the delay for the move into it.
  5. Shut, the card is not only behind the walls, it is visibility: hidden; it is turned on at the moment it starts to rise, still inside the box, and off again once it is back in. Left drawn, the browser’s sorting of the card’s plane against the front wall’s let a sliver of its badge through the wall.
  6. The camera looks at the box from a gentle 24° above, and it is the box’s own size that fills the frame: at --u: 0.41vmin the shut box is 42–43vmin tall. Open, the lid stops at rotateX(-32deg), short of upright, so lid and card stay inside the 70vmin band; as it opens the whole box sinks 26 units (translate, on top of the swaying transform), so the shut box and the open one are both centred.
  7. The hovered element is a static wrapper; the box inside has pointer-events: none. A hovered element that moves away from the pointer would flicker.

Key techniques

  • lid hinged with transform-origin: bottom
  • open-top box from 4 walls + base
  • transition-delay swaps between open and close
  • static hit area

Copy-paste code

HTML 13 lines

<div class="scene">
  <div class="package" tabindex="0">
    <div class="box">
      <i class="wall back"></i>
      <i class="wall left"></i>
      <i class="wall right"></i>
      <i class="base"></i>
      <div class="card"><b></b><span>NEW</span></div>
      <i class="wall front"><em>CUBE · 01</em></i>
      <i class="lid"></i>
    </div>
  </div>
</div>

CSS 170 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the box is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.41vmin;
  perspective: calc(800 * var(--u));
}

/* the static hit area */
.package {
  --card: #4a3e8d;
  --card-dark: #2e2a5e;
  display: grid;
  place-items: center;
  /* just wider than the box, and as tall as it is open, so the pointer on the lid keeps it open */
  width: calc(136 * var(--u));
  height: calc(170 * var(--u));
  border-radius: calc(16 * var(--u));
  outline-offset: calc(-4 * var(--u));
  cursor: pointer;
  transform-style: preserve-3d;
}

/* 88 wide, 88 deep, 60 tall */
.box {
  position: relative;
  width: calc(88 * var(--u));
  height: calc(60 * var(--u));
  pointer-events: none;
  transform-style: preserve-3d;
  animation: sway 5s ease-in-out infinite alternate;
  translate: 0 0;
  transition: translate 0.55s ease-in-out 0.3s; /* closing: sinks back up with the lid */
}

.wall {
  position: absolute;
  inset: 0;
  /* an inner line plus a soft shade instead of a hard border: squeezed side-on, a one-unit line
     breaks up, the shade survives */
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) #a893fe,
    inset 0 0 calc(8 * var(--u)) rgb(168 147 254 / 0.18);
  /* a ribbon down the middle */
  background:
    linear-gradient(90deg, transparent 42%, #ff4d9d 42% 58%, transparent 58%),
    linear-gradient(var(--card), var(--card-dark));
}

.front {
  display: grid;
  place-items: end start;
  padding: calc(6 * var(--u)) calc(7 * var(--u));
  backface-visibility: hidden; /* from behind you look INTO the box */
  transform: translateZ(calc(44 * var(--u)));
}

.back { transform: rotateY(180deg) translateZ(calc(44 * var(--u))); }
.left { transform: rotateY(-90deg) translateZ(calc(44 * var(--u))); }
.right { transform: rotateY(90deg) translateZ(calc(44 * var(--u))); }

.left,
.right {
  background: linear-gradient(var(--card-dark), #201d42);
}

.front em {
  color: #fff;
  font: normal 800 calc(9 * var(--u)) Inter, system-ui;
  letter-spacing: calc(1 * var(--u));
}

/* an 88 × 88 square centred on the box, laid flat at the bottom edge */
.base {
  position: absolute;
  top: calc(50% - 44 * var(--u));
  left: 0;
  width: calc(88 * var(--u));
  height: calc(88 * var(--u));
  background: #1c1938;
  transform: rotateX(-90deg) translateZ(calc(30 * var(--u)));
}

/* stands above the back wall, hinged on its bottom edge = the top-back edge of the box */
.lid {
  position: absolute;
  top: calc(-88 * var(--u));
  left: 0;
  box-sizing: border-box;
  width: calc(88 * var(--u));
  height: calc(88 * var(--u));
  /* like the walls */
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) #a48cff,
    inset 0 0 calc(8 * var(--u)) rgb(164 140 255 / 0.18);
  background:
    linear-gradient(90deg, transparent 42%, #ff4d9d 42% 58%, transparent 58%),
    linear-gradient(#6e65a4, var(--card));
  transform-origin: 50% 100%;
  /* at rest the lid is shut, laid flat over the opening */
  transform: translateZ(calc(-44 * var(--u))) rotateX(-90deg);
  transition: transform 0.55s ease-in-out 0.3s; /* closing: wait for the card */
}

.card {
  position: absolute;
  top: calc(5 * var(--u));
  left: calc(50% - 31 * var(--u));
  display: grid;
  place-items: center;
  align-content: center;
  gap: calc(5 * var(--u));
  box-sizing: border-box;
  width: calc(62 * var(--u));
  height: calc(52 * var(--u));
  border: calc(1 * var(--u)) solid rgb(255 255 255 / 0.5);
  border-radius: calc(8 * var(--u));
  /* deep enough for its white NEW where the badge is seen — 5.8:1 with the box open; the bright
     #2ee6d6 and #8b6cff were 3.7:1 */
  background: linear-gradient(140deg, #087a70, #6a45f5 60%, #d1206f);
  box-shadow: inset 0 0 calc(14 * var(--u)) rgb(255 255 255 / 0.25);
  color: #fff;
  font: 900 calc(10 * var(--u)) Inter, system-ui;
  letter-spacing: calc(1.5 * var(--u));
  /* at rest it is inside the shut box, out of sight, and NOT DRAWN: with the card left drawn, the
     browser's sorting of the two planes let a sliver of its NEW through the front wall, white on
     light violet at 3.7:1. It is turned on as it starts to rise, while the walls still hide it. */
  visibility: hidden;
  transform: translateY(0);
  transition: transform 0.5s ease-in-out, visibility 0s 0.5s; /* closing: gone once it is back in */
}

/* the "product": a little gem */
.card b {
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
  border-radius: calc(4 * var(--u));
  background: linear-gradient(135deg, #fff, rgb(255 255 255 / 0.35));
  transform: rotate(45deg);
}

.package:hover .lid,
.package:focus-visible .lid {
  /* lifted most of the way up, leaning a little towards you: upright or leaning back, the lid
     would stand taller than the band has room for */
  transform: translateZ(calc(-44 * var(--u))) rotateX(-32deg);
  transition: transform 0.7s cubic-bezier(0.3, 1.15, 0.5, 1);
}

/* the box sinks as the lid opens, by half of what the open lid adds on top, so shut and open are
   both centred */
.package:hover .box,
.package:focus-visible .box {
  translate: 0 calc(26 * var(--u));
  transition: translate 0.7s ease-in-out;
}

/* opening: the card waits until the lid is out of the way */
.package:hover .card,
.package:focus-visible .card {
  visibility: visible;
  transform: translateY(calc(-50 * var(--u)));
  transition: transform 0.6s cubic-bezier(0.3, 1.3, 0.5, 1) 0.3s, visibility 0s 0.3s;
}

/* the camera looks down at a gentle 24deg; the box sits 2 units above its layout box, which
   centres it shut */
@keyframes sway {
  from { transform: translateY(calc(-2 * var(--u))) rotateX(-24deg) rotateY(-38deg); }
  to   { transform: translateY(calc(-2 * var(--u))) rotateX(-24deg) rotateY(-22deg); }
}
Standalone snippet · plain CSS, no build step, no dependencies.