Pure CSS Products & branding #hover #product #device #phone

Foldable phone in pure CSS

A book-style foldable phone, shut, showing its cover screen. Hover or focus and the right half swings open on its hinge like a page while the phone slides over to stay centred, and the big inner screen lights up across both halves. Each half is a thin slab of real faces, and the hinge spine turns at half the fold angle so it is always round the outside.

  • 283 lines of CSS
  • 21 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Each half is a thin slab: the screen side at z = 0, a core halfway in, the back at z = −5 turned rotateY(180deg), and a strip on its top, bottom and outer edge. The core is the trick for the rounded corners: the edge strips stop where a corner starts to curve, and the core, the same rounded shape, fills the gap so no daylight shows through.
  2. The left half never moves. The right half is a 0 × 0 wrapper sitting on the hinge line, so its own rotateY() turns it about the hinge: -180deg folds it toward you and face down onto the left half, which is shut, and 0deg lays it flat beside it, which is open. Shut, its back is what faces you, so the cover screen lives there, and it reads the right way round because the two half-turns cancel.
  3. The spine is a strip behind the hinge that turns at half the fold angle, -90deg when shut. A hinge cover does exactly that: it sits flat behind the open phone and stands across the fold when it is shut, always on the outside of the bend.
  4. The inner screen is one wallpaper in two pieces: both halves paint the same gradient at background-size: 200% 100%, the left showing its left half and the right its right, so the picture runs straight across the fold. It fades in with opacity as the phone opens, after a short delay, as a real screen wakes.
  5. Shut, the phone is the left half only, so a wrapper slides it right by half a width to centre it, and back to 0 as it opens. The hovered element is a static wrapper with tabindex="0", so focus opens it too. Every length is a multiple of one base unit, --u.

Key techniques

  • rotateY(-180deg) about the hinge edge
  • spine at half the fold angle
  • one wallpaper split with background-size: 200%
  • translateX keeps the shut and open phone centred

Copy-paste code

HTML 21 lines

<div class="scene">
  <div class="phone" tabindex="0" role="img" aria-label="A foldable phone: hover or focus to open it like a book">
    <div class="view">
      <div class="slide">
        <div class="half left">
          <b class="face screen"><span class="display"><b class="time">10:08</b><b class="card"></b></span></b>
          <b class="face core"></b>
          <b class="face back"></b>
          <b class="edge top"></b><b class="edge bottom"></b><b class="edge outer"></b>
        </div>
        <div class="half leaf">
          <b class="face screen"><span class="display"><span class="apps"><i style="--a:#8b6cff; --b:#5a3fd6"></i><i style="--a:#2ee6d6; --b:#12a397"></i><i style="--a:#ff4d9d; --b:#c81f6c"></i><i style="--a:#ffb547; --b:#e07a12"></i><i style="--a:#ff4d9d; --b:#8b6cff"></i><i style="--a:#2ee6d6; --b:#8b6cff"></i><i style="--a:#ffb547; --b:#ff4d9d"></i><i style="--a:#8b6cff; --b:#2ee6d6"></i><i style="--a:#eceefb; --b:#9aa0c8"></i></span></span></b>
          <b class="face core"></b>
          <b class="face back cover"><span class="glass"><b class="time">10:08</b><b class="rings"></b></span></b>
          <b class="edge top"></b><b class="edge bottom"></b><b class="edge outer"></b>
        </div>
        <b class="spine"></b>
      </div>
    </div>
  </div>
</div>

CSS 283 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the phone is the same share of a
     card, the editor, a full screen and a recording canvas. A long lens (1100 units), so the half
     that swings toward you mid-fold is not blown up */
  --u: 0.28vmin;
  perspective: calc(1100 * var(--u));
}

/* the static hit area: the shut phone's box. It never moves, so the hover cannot flicker */
.phone {
  --w: calc(100 * var(--u)); /* a half's width */
  --h: calc(210 * var(--u)); /* the phone's height */
  --t: calc(5 * var(--u));   /* a half's thickness */
  --r: calc(14 * var(--u));  /* the outer corners */
  --frame: linear-gradient(90deg, #4b4766, #8e8aad 45%, #3a3752);
  position: relative;
  display: grid;
  place-items: center;
  width: calc(116 * var(--u));
  height: calc(226 * var(--u));
  border-radius: calc(18 * var(--u));
  cursor: pointer;
  outline: none;
  transform-style: preserve-3d;
}

/* the focus ring: round the open phone, since focus opens it; it takes no pointer */
.phone:focus-visible::before {
  content: '';
  position: absolute;
  inset: calc(-6 * var(--u)) calc(-50 * var(--u));
  border: calc(2 * var(--u)) solid #8b6cff;
  border-radius: calc(18 * var(--u));
  pointer-events: none;
}

/* 0 × 0 at the middle of the hinge. Seen from the left, so the half that swings out toward you
   shows you its face all the way round instead of passing edge-on; shut, the cover and both
   halves' edges show. Open, it turns nearer to face you */
.view {
  width: 0;
  height: 0;
  pointer-events: none;
  transform-style: preserve-3d;
  transform: rotateX(-8deg) rotateY(26deg);
  transition: transform 1.4s cubic-bezier(0.45, 0, 0.2, 1);
}

/* shut, the phone is only the left half, -w to 0: slid right by half a width, it is centred */
.slide {
  transform-style: preserve-3d;
  transform: translateX(calc(var(--w) / 2));
  transition: transform 1.4s cubic-bezier(0.45, 0, 0.2, 1);
}

.half {
  position: absolute;
  transform-style: preserve-3d;
}

/* the right half turns about the hinge line, which is its own 0 × 0 origin. Shut: folded toward
   you, face down on the left half */
.leaf {
  transform: rotateY(-180deg);
  transition: transform 1.4s cubic-bezier(0.45, 0, 0.2, 1);
}

/* --- the faces: the screen side, a core halfway in, the back. The left half runs from -w to 0,
   the right from 0 to w; only the outer corners are rounded --- */
.face {
  position: absolute;
  top: calc(var(--h) / -2);
  width: var(--w);
  height: var(--h);
  box-sizing: border-box;
}
.left .face { left: calc(var(--w) * -1); border-radius: var(--r) 0 0 var(--r); }
.leaf .face { left: 0; border-radius: 0 var(--r) var(--r) 0; }

.screen {
  background: #0c0c14;
  backface-visibility: hidden;
}
/* the core: plugs the rounded corners, where the edge strips stop */
.core {
  background: #5d5980;
  transform: translateZ(calc(var(--t) / -2));
}
/* the back: turned round, so its own corners are mirrored */
.back {
  background: linear-gradient(160deg, #3d3863, #262340);
  backface-visibility: hidden;
  transform: translateZ(calc(var(--t) * -1)) rotateY(180deg);
}
.left .back { border-radius: 0 var(--r) var(--r) 0; }
.leaf .back { border-radius: var(--r) 0 0 var(--r); }

/* --- the edges: strips standing on the top, bottom and outer side, stopping where a corner
   curves. Each is centred on its edge, then stood up --- */
.edge {
  position: absolute;
  background: var(--frame);
}
.edge.top,
.edge.bottom {
  width: calc(var(--w) - var(--r));
  height: var(--t);
}
.edge.top { top: calc((var(--h) + var(--t)) / -2); transform: translateZ(calc(var(--t) / -2)) rotateX(90deg); }
.edge.bottom { top: calc((var(--h) - var(--t)) / 2); transform: translateZ(calc(var(--t) / -2)) rotateX(90deg); }
.left .edge.top,
.left .edge.bottom { left: calc((var(--w) - var(--r)) * -1); }
.leaf .edge.top,
.leaf .edge.bottom { left: 0; }
.edge.outer {
  top: calc(var(--h) / -2 + var(--r));
  width: var(--t);
  height: calc(var(--h) - 2 * var(--r));
  background: linear-gradient(#8e8aad, #4b4766);
  transform: translateZ(calc(var(--t) / -2)) rotateY(90deg);
}
.left .edge.outer { left: calc(var(--w) * -1 - var(--t) / 2); }
.leaf .edge.outer { left: calc(var(--w) - var(--t) / 2); }

/* the spine: a rounded strip across the hinge, its face turned away from the screens, half a unit
   behind the hinge line. Open, that is inside the slabs, out of sight; it turns about the hinge
   line at half the fold angle, so shut it stands across the fold, flush with both halves' edges */
.spine {
  position: absolute;
  left: calc(var(--t) * -1);
  top: calc(var(--h) / -2 + 3 * var(--u));
  width: calc(var(--t) * 2);
  height: calc(var(--h) - 6 * var(--u));
  border-radius: calc(4 * var(--u));
  background: linear-gradient(90deg, #2f2c46, #a6a2c4 50%, #2f2c46);
  transform: rotateY(-90deg) translateZ(calc(-0.5 * var(--u))) rotateY(180deg);
  transition: transform 1.4s cubic-bezier(0.45, 0, 0.2, 1);
}

/* --- the inner screen: one wallpaper across both halves, dark until it opens --- */
.display {
  position: absolute;
  inset: calc(4 * var(--u)) 0;
  overflow: hidden;
  background:
    radial-gradient(circle at 18% 88%, rgb(255 77 157 / 0.55), transparent 32%),
    radial-gradient(circle at 86% 12%, rgb(46 230 214 / 0.45), transparent 30%),
    linear-gradient(135deg, #1b1248, #2c176e 45%, #101f4a);
  background-size: 200% 100%;
  opacity: 0;
  transition: opacity 0.7s;
}
.left .display { left: calc(4 * var(--u)); border-radius: calc(var(--r) - 4 * var(--u)) 0 0 calc(var(--r) - 4 * var(--u)); background-position: 0 0; }
.leaf .display { right: calc(4 * var(--u)); border-radius: 0 calc(var(--r) - 4 * var(--u)) calc(var(--r) - 4 * var(--u)) 0; background-position: 100% 0; }
/* the fold itself: a soft shade along the crease */
.display::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(var(--to), rgb(0 0 0 / 0.35), transparent calc(8 * var(--u)));
}
.left .display::after { --to: to left; }
.leaf .display::after { --to: to right; }

/* white on the darkest part of the wallpaper */
.display .time {
  position: absolute;
  left: calc(10 * var(--u));
  top: calc(18 * var(--u));
  font: 700 calc(30 * var(--u)) / 1 Inter, system-ui, sans-serif;
  letter-spacing: calc(-0.6 * var(--u));
  color: #fff;
}
/* a widget: a sun and two lines, no words */
.card {
  position: absolute;
  left: calc(10 * var(--u));
  top: calc(64 * var(--u));
  width: calc(76 * var(--u));
  height: calc(40 * var(--u));
  border-radius: calc(9 * var(--u));
  background:
    radial-gradient(circle at calc(19 * var(--u)) 50%, #ffb547 0 calc(9 * var(--u)), transparent calc(9.5 * var(--u))),
    linear-gradient(rgb(255 255 255 / 0.8) 0 0) calc(36 * var(--u)) calc(13 * var(--u)) / calc(30 * var(--u)) calc(4 * var(--u)) no-repeat,
    linear-gradient(rgb(255 255 255 / 0.45) 0 0) calc(36 * var(--u)) calc(23 * var(--u)) / calc(20 * var(--u)) calc(4 * var(--u)) no-repeat,
    rgb(255 255 255 / 0.12);
}

/* nine app icons, three by three */
.apps {
  position: absolute;
  left: calc(10 * var(--u));
  top: calc(20 * var(--u));
  display: grid;
  grid-template-columns: repeat(3, calc(20 * var(--u)));
  gap: calc(9 * var(--u));
}
.apps i {
  height: calc(20 * var(--u));
  border-radius: calc(6 * var(--u));
  background: linear-gradient(135deg, var(--a), var(--b));
}

/* --- the cover screen, on the right half's back: what faces you when it is shut --- */
.glass {
  position: absolute;
  inset: calc(4 * var(--u));
  border-radius: calc(var(--r) - 4 * var(--u));
  /* a deep violet lock screen with a sheen across the glass: dark enough for white words, and
     never mistakable for the stage behind it */
  background:
    linear-gradient(115deg, transparent 30%, rgb(255 255 255 / 0.06) 30% 46%, transparent 46%),
    #191536;
}
/* the punch-hole camera */
.glass::before {
  content: '';
  position: absolute;
  left: calc(50% - 3 * var(--u));
  top: calc(6 * var(--u));
  width: calc(6 * var(--u));
  height: calc(6 * var(--u));
  border-radius: 50%;
  background: #2b2a40;
}
.glass .time {
  position: absolute;
  inset: calc(34 * var(--u)) 0 auto;
  text-align: center;
  font: 700 calc(30 * var(--u)) / 1 Inter, system-ui, sans-serif;
  letter-spacing: calc(-0.6 * var(--u));
  color: #fff;
}
/* two activity rings: teal and pink arcs on their dim tracks */
.rings {
  position: absolute;
  left: calc(50% - 26 * var(--u));
  top: calc(96 * var(--u));
  width: calc(52 * var(--u));
  height: calc(52 * var(--u));
  border-radius: 50%;
  background:
    radial-gradient(closest-side, #191536 calc(100% - 6 * var(--u)), transparent calc(100% - 5.6 * var(--u))),
    conic-gradient(#2ee6d6 0 72%, rgb(46 230 214 / 0.2) 72%);
}
.rings::after {
  content: '';
  position: absolute;
  inset: calc(9 * var(--u));
  border-radius: 50%;
  background:
    radial-gradient(closest-side, #191536 calc(100% - 6 * var(--u)), transparent calc(100% - 5.6 * var(--u))),
    conic-gradient(#ff4d9d 0 45%, rgb(255 77 157 / 0.2) 45%);
}

/* --- open: on hover or focus --- */
.phone:hover .view,
.phone:focus-visible .view,
.phone:focus-within .view {
  transform: rotateX(-6deg) rotateY(12deg);
}
.phone:hover .slide,
.phone:focus-visible .slide,
.phone:focus-within .slide {
  transform: translateX(0);
}
.phone:hover .leaf,
.phone:focus-visible .leaf,
.phone:focus-within .leaf {
  transform: rotateY(0deg);
}
.phone:hover .spine,
.phone:focus-visible .spine,
.phone:focus-within .spine {
  transform: rotateY(0deg) translateZ(calc(-0.5 * var(--u))) rotateY(180deg);
}
/* the screen wakes as it opens */
.phone:hover .display,
.phone:focus-visible .display,
.phone:focus-within .display {
  opacity: 1;
  transition: opacity 0.6s 0.5s;
}
Standalone snippet · plain CSS, no build step, no dependencies.