Back to Resources

IPhoneDuoFrame Component

An interactive foldable iPhone frame with a cover screen and expansive inner display

iPhone DuoFoldable iPhoneDevice FrameReactUI ComponentsInteractive

September 2026 · Components

Foldable playground

Cover screen placeholder, 1080 by 1540 pixels

5.4-inch outer display

One device. Room to unfold.

A foldable frame based on the 5.4-inch outer and 7.6-inch inner display brief. Fold it closed to show the compact cover screen, or unfold it around the center hinge to reveal one continuous inner display. Supply separate content for the cover and expanded views.

Usage

import { IPhoneDuoFrame } from "./components/ui/IPhoneDuoFrame";

<IPhoneDuoFrame
  defaultOpen={false}
  cover={
    <img src="https://placehold.co/1080x1540" alt="Compact app view"
      className="h-full w-full object-cover" />
  }
>
  <img src="https://placehold.co/2160x1540" alt="Expanded app view"
    className="h-full w-full object-cover" />
</IPhoneDuoFrame>

Copy the two source files below into your components/ui folder. Replace the sample images with screenshots, videos, or React content. The frame includes its own keyboard-accessible fold control and respects reduced motion preferences. Display proportions are illustrative.

IPhoneDuoFrame.js
import { useId, useState } from "react";
import styles from "./IPhoneDuoFrame.module.css";

/** One foldable device with a cover screen and a continuous inner display. */
export function IPhoneDuoFrame({
  children,
  cover,
  open,
  defaultOpen = true,
  onOpenChange,
  className = "",
}) {
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
  const isOpen = open ?? internalOpen;
  const screenId = useId();

  function toggle() {
    if (open === undefined) setInternalOpen(!isOpen);
    onOpenChange?.(!isOpen);
  }

  return (
    <div className={`${styles.frame} ${className}`} data-open={isOpen}>
      <div className={styles.stage}>
        <div className={styles.device}>
          <div className={styles.base} aria-hidden="true" />
          <div className={styles.leaf}>
            <div className={styles.inside} aria-hidden="true" />
            <div className={styles.outside}>
              <div
                className={styles.cover}
                aria-hidden={isOpen}
                inert={isOpen ? "" : undefined}
              >
                {cover}
              </div>
              <span className={styles.camera} aria-hidden="true" />
              <span className={styles.home} aria-hidden="true" />
            </div>
          </div>
          <div
            id={screenId}
            className={styles.inner}
            aria-hidden={!isOpen}
            inert={!isOpen ? "" : undefined}
          >
            {children}
            <span className={styles.crease} aria-hidden="true" />
            <span className={styles.home} aria-hidden="true" />
          </div>
        </div>
      </div>
      <div className={styles.controls}>
        <button
          type="button"
          onClick={toggle}
          aria-expanded={isOpen}
          aria-controls={screenId}
          className={styles.toggle}
        >
          {isOpen ? "Fold closed" : "Unfold device"}
          <span aria-hidden="true">{isOpen ? "↤" : "↔"}</span>
        </button>
        <p className={styles.caption} aria-live="polite">
          {isOpen ? "7.6-inch inner display" : "5.4-inch outer display"}
        </p>
      </div>
    </div>
  );
}
IPhoneDuoFrame.module.css
.frame {
  width: 100%;
  max-width: 600px;
  margin-inline: auto;
  container-type: inline-size;
  --corner: clamp(16px, 6cqw, 36px);
  --bezel: clamp(4px, 1.2cqw, 7px);
}
.stage {
  padding: 12% 5%;
  perspective: 1500px;
}
.device {
  position: relative;
  width: 100%;
  aspect-ratio: 404 / 288;
  transform-style: preserve-3d;
  transition: transform 850ms cubic-bezier(0.22, 0.7, 0.2, 1);
}
.base,
.leaf {
  position: absolute;
  top: 0;
  width: 50%;
  height: 100%;
}
.base {
  right: 0;
  background: #141418;
  border: 2px solid #5d5d60;
  border-left: 0;
  border-radius: 0 var(--corner) var(--corner) 0;
  box-shadow:
    2px 3px 4px #11182720,
    inset -2px 0 0 #232326;
}
.leaf {
  left: 0;
  transform-origin: right center;
  transform-style: preserve-3d;
  transition: transform 850ms cubic-bezier(0.22, 0.7, 0.2, 1);
}
.inside,
.outside {
  position: absolute;
  inset: 0;
  border: 2px solid #5d5d60;
  background: #141418;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}
.inside {
  border-right: 0;
  border-radius: var(--corner) 0 0 var(--corner);
  box-shadow:
    -2px 3px 4px #11182720,
    inset 2px 0 0 #232326;
}
.outside {
  transform: rotateY(180deg) translateZ(1px);
  border-radius: 5px var(--corner) var(--corner) 5px;
  box-shadow: inset 0 0 0 2px #33333b;
}
.cover {
  position: absolute;
  inset: var(--bezel);
  border-radius: 2px calc(var(--corner) - var(--bezel))
    calc(var(--corner) - var(--bezel)) 2px;
  overflow: hidden;
  background: #222338;
}
.inner {
  position: absolute;
  inset: var(--bezel);
  border-radius: calc(var(--corner) - var(--bezel));
  background: #222338;
  overflow: hidden;
  transform: translateZ(2px);
  opacity: 1;
  visibility: visible;
  transition:
    opacity 220ms 620ms,
    visibility 0s 620ms;
}
.frame[data-open="false"] .device {
  transform: translateX(-25%);
}
.frame[data-open="false"] .leaf {
  transform: rotateY(180deg);
}
.frame[data-open="false"] .inner {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition:
    opacity 100ms,
    visibility 0s 100ms;
}
.crease {
  position: absolute;
  inset: 0 auto 0 49%;
  width: 2%;
  background: linear-gradient(
    90deg,
    transparent,
    #00000003,
    #ffffff03,
    transparent
  );
  pointer-events: none;
}
.camera {
  position: absolute;
  top: 6.5%;
  width: 6%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(circle at 40% 30%, #38435c, #030308 60%);
  box-shadow: 0 0 0 2px #08080c;
  pointer-events: none;
}
.camera {
  right: 8.5%;
}
.home {
  position: absolute;
  bottom: 1%;
  left: 36%;
  width: 28%;
  height: clamp(2px, 0.5cqw, 3px);
  border-radius: 99px;
  background: #fffffff0;
  pointer-events: none;
}
.outside .home {
  bottom: 3.5%;
}
.controls {
  text-align: center;
  padding-bottom: 10px;
}
.toggle {
  display: inline-flex;
  align-items: center;
  gap: 24px;
  padding: 12px 20px;
  border-radius: 999px;
  background: #18181b;
  color: white;
  font-size: 14px;
  cursor: pointer;
}
.toggle:hover {
  background: #3f3f46;
}
.toggle:focus-visible {
  outline: 3px solid #8b78e6;
  outline-offset: 4px;
}
.caption {
  margin-top: 12px;
  color: #71717a;
  font-size: 12px;
}
.outside::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: -5px;
  width: 4px;
  border-radius: 3px 0 0 3px;
  background: linear-gradient(
    90deg,
    #414144,
    #c7c7c9 45%,
    #626265 80%,
    #242427
  );
}
.base::before,
.base::after {
  content: "";
  position: absolute;
  top: -4px;
  width: 14%;
  height: 3px;
  border-radius: 2px 2px 0 0;
  background: linear-gradient(#9d9d9f, #333336);
}
.base::before {
  right: 22%;
}
.base::after {
  right: 42%;
}
@media (prefers-reduced-motion: reduce) {
  .device,
  .leaf,
  .inner,
  .frame[data-open="false"] .inner {
    transition: none;
  }
}

Props

  • children — content spanning the entire unfolded inner screen.
  • cover — content for the outer screen when folded closed.
  • defaultOpen — initial unfolded state. Default: true.
  • open and onOpenChange — optional controlled state and change callback. Pass both to manage the fold state yourself.
  • className — additional classes for the responsive wrapper.