Created
May 26, 2026 18:49
-
-
Save milushov/4548b8a63a4f29b4ffa3b812d284228c to your computer and use it in GitHub Desktop.
Lapster island toast — DI grows DOWN, top stays as DI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>Island Toast Morph — DI grows DOWN, top stays as DI</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <style> | |
| :root { | |
| --bg: #f2f2f7; /* iOS systemGroupedBackground */ | |
| --card: #ffffff; | |
| --label: #000; | |
| --label-2: #3c3c4399; | |
| --tint: #007aff; /* iOS systemBlue */ | |
| --separator: #3c3c432e; | |
| --green: #34c759; | |
| --pill-bg: #000; | |
| --pill-fg: #fff; | |
| /* DI physical dimensions on iPhone 15/16 Pro (approx) */ | |
| --di-top: 11px; | |
| --di-width: 124px; | |
| --di-height: 36px; | |
| /* Toast EXPANDED dimensions */ | |
| --toast-width: calc(100% - 24px); /* matches LapsterTheme.Spacing.md horizontal */ | |
| --toast-height: 88px; /* DI height (36) + content slab (52) */ | |
| --t: 0.55s cubic-bezier(0.22, 1, 0.36, 1); | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { margin: 0; padding: 0; } | |
| body { | |
| font-family: -apple-system, "SF Pro Text", BlinkMacSystemFont, "Helvetica Neue", sans-serif; | |
| background: linear-gradient(180deg, #cfd4dc 0%, #a7b0ba 100%); | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 28px 16px 60px; | |
| color: #1c1c1e; | |
| } | |
| h1 { | |
| font-size: 17px; | |
| font-weight: 600; | |
| margin: 0 0 4px; | |
| color: #fff; | |
| text-shadow: 0 1px 2px #00000040; | |
| } | |
| p.subtitle { | |
| margin: 0 0 24px; | |
| font-size: 13px; | |
| color: #ffffffcc; | |
| text-shadow: 0 1px 2px #00000040; | |
| } | |
| /* ───────── iPhone frame ───────── */ | |
| .iphone { | |
| width: 393px; | |
| height: 852px; | |
| border-radius: 56px; | |
| background: #1c1c1e; | |
| box-shadow: | |
| 0 30px 80px #00000055, | |
| inset 0 0 0 2px #2c2c2e, | |
| inset 0 0 0 8px #000; | |
| position: relative; | |
| overflow: hidden; | |
| margin-bottom: 20px; | |
| } | |
| /* App screen — clipped to inside the iPhone bezel */ | |
| .screen { | |
| position: absolute; | |
| inset: 8px; | |
| border-radius: 48px; | |
| overflow: hidden; | |
| background: var(--bg); | |
| } | |
| /* ───────── Status bar ───────── */ | |
| .statusbar { | |
| height: 54px; | |
| display: flex; | |
| align-items: flex-end; | |
| justify-content: space-between; | |
| padding: 0 28px 8px; | |
| font-size: 15px; | |
| font-weight: 600; | |
| color: var(--label); | |
| } | |
| .statusbar .time { letter-spacing: -0.3px; } | |
| .statusbar .icons { | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| } | |
| .icons .signal, .icons .wifi, .icons .battery { | |
| display: inline-block; | |
| } | |
| .signal { width: 18px; height: 11px; | |
| background: | |
| linear-gradient(to top, #000 0 25%, transparent 25% 100%) 0 100%/3px 100% no-repeat, | |
| linear-gradient(to top, #000 0 50%, transparent 50% 100%) 5px 100%/3px 100% no-repeat, | |
| linear-gradient(to top, #000 0 75%, transparent 75% 100%) 10px 100%/3px 100% no-repeat, | |
| linear-gradient(to top, #000 0 100%, transparent 0) 15px 100%/3px 100% no-repeat; | |
| } | |
| .wifi { | |
| width: 16px; height: 11px; | |
| border-radius: 3px; | |
| background: radial-gradient(circle at 50% 100%, transparent 0 2px, #000 2px 3px, transparent 3px 5px, #000 5px 6px, transparent 6px 8px, #000 8px 9px, transparent 9px 100%); | |
| } | |
| .battery { | |
| width: 28px; height: 12px; | |
| border: 1.5px solid #000; | |
| border-radius: 3px; | |
| position: relative; | |
| padding: 1px; | |
| } | |
| .battery::after { | |
| content:""; position: absolute; right: -3px; top: 3px; | |
| width: 2px; height: 4px; background: #000; border-radius: 0 1px 1px 0; | |
| } | |
| .battery::before { | |
| content:""; position: absolute; inset: 1.5px; | |
| background: var(--green); border-radius: 1.5px; | |
| width: calc(60% - 3px); | |
| } | |
| /* ───────── Dynamic Island (real, physical hardware cutout) ───────── */ | |
| .di { | |
| position: absolute; | |
| top: var(--di-top); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: var(--di-width); | |
| height: var(--di-height); | |
| background: #000; | |
| border-radius: 100px; | |
| z-index: 50; | |
| pointer-events: none; | |
| } | |
| /* ───────── Toast pill (morphs OUT of DI, downward) ───────── | |
| Anchored to the DI top. Width and height animate. Top stays at DI top. | |
| The TOP 36pt of the pill ALWAYS matches the DI footprint — at rest | |
| the pill IS the DI; when expanded the pill extends BELOW the DI, | |
| leaving the DI region as a flat black "header" with no content. */ | |
| .toast { | |
| position: absolute; | |
| top: var(--di-top); | |
| left: 50%; | |
| width: var(--di-width); | |
| height: var(--di-height); | |
| background: #000; | |
| border-radius: 100px; | |
| z-index: 60; | |
| transform: translateX(-50%) scale(0.01); | |
| transform-origin: top center; | |
| opacity: 0; | |
| transition: | |
| width var(--t), | |
| height var(--t), | |
| border-radius var(--t), | |
| transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), | |
| opacity 0.25s ease-out; | |
| box-shadow: 0 16px 32px #00000040; | |
| overflow: hidden; | |
| } | |
| .toast.materialised { | |
| transform: translateX(-50%) scale(1); | |
| opacity: 1; | |
| } | |
| .toast.expanded { | |
| width: var(--toast-width); | |
| height: var(--toast-height); | |
| border-radius: 32px; | |
| } | |
| /* Content row — sits BELOW the DI region (top: var(--di-height)). | |
| Becomes visible only after the pill has grown wide enough to host it. */ | |
| .toast .content { | |
| position: absolute; | |
| left: 0; | |
| right: 0; | |
| top: var(--di-height); /* leave the DI region as blank black */ | |
| bottom: 0; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 0 16px 8px; | |
| opacity: 0; | |
| transform: translateY(-4px); | |
| transition: opacity 0.3s ease-out 0.2s, transform 0.35s ease-out 0.2s; | |
| } | |
| .toast.expanded .content { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| .toast .badge { | |
| width: 28px; height: 28px; | |
| border-radius: 50%; | |
| background: var(--green); | |
| color: #fff; | |
| display: grid; place-items: center; | |
| flex: 0 0 28px; | |
| box-shadow: 0 2px 6px #00000040; | |
| } | |
| .toast .badge svg { width: 14px; height: 14px; } | |
| .toast .text { | |
| display: flex; flex-direction: column; | |
| gap: 1px; flex: 1; min-width: 0; | |
| } | |
| .toast .title { | |
| font-size: 13px; font-weight: 600; | |
| color: var(--pill-fg); | |
| line-height: 1.2; | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| } | |
| .toast .body { | |
| font-size: 11px; font-weight: 400; | |
| color: #ffffffbf; | |
| line-height: 1.25; | |
| display: -webkit-box; | |
| -webkit-line-clamp: 2; | |
| -webkit-box-orient: vertical; | |
| overflow: hidden; | |
| } | |
| /* ───────── Settings list (Lapster app chrome behind the toast) ───────── */ | |
| .header { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 8px 16px 16px; | |
| position: relative; | |
| } | |
| .header .back { | |
| color: var(--tint); font-size: 17px; | |
| display: flex; align-items: center; gap: 4px; | |
| font-weight: 400; | |
| } | |
| .header .title-screen { | |
| position: absolute; left: 50%; top: 16px; | |
| transform: translateX(-50%); | |
| font-size: 17px; font-weight: 600; | |
| } | |
| .section-title { | |
| font-size: 13px; color: var(--label-2); | |
| text-transform: uppercase; | |
| padding: 16px 32px 8px; | |
| letter-spacing: 0.3px; | |
| } | |
| .group { | |
| background: var(--card); | |
| margin: 0 16px; | |
| border-radius: 14px; | |
| overflow: hidden; | |
| } | |
| .row { | |
| padding: 12px 16px; | |
| color: var(--tint); | |
| font-size: 17px; | |
| border-bottom: 1px solid var(--separator); | |
| } | |
| .row:last-child { border-bottom: 0; } | |
| /* ───────── Controls ───────── */ | |
| .controls { | |
| display: flex; gap: 12px; flex-wrap: wrap; | |
| justify-content: center; | |
| margin-top: 8px; | |
| } | |
| button.cta { | |
| background: #fff; | |
| color: #1c1c1e; | |
| border: none; | |
| padding: 10px 18px; | |
| border-radius: 12px; | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| box-shadow: 0 2px 8px #00000020; | |
| transition: transform 0.1s; | |
| } | |
| button.cta:active { transform: scale(0.97); } | |
| button.cta.secondary { | |
| background: #ffffff20; | |
| color: #fff; | |
| backdrop-filter: blur(8px); | |
| } | |
| /* ───────── Animation script — phase markers ───────── | |
| 0 → invisible dot at DI center | |
| 0–250 → materialise (scale 0→1) — pill matches DI exactly | |
| 330 → begin expand (width grows + height grows DOWNWARD) | |
| 880 → fully expanded; content row visible BELOW DI region | |
| 3880 → begin collapse back to DI footprint | |
| 4330 → fully collapsed (DI-sized again); begin dematerialise | |
| 4580 → invisible, animation done | |
| */ | |
| @keyframes lifecycle { | |
| 0%, 100% { /* hidden */ } | |
| } | |
| /* loop marker */ | |
| .loop-hint { | |
| font-size: 12px; color: #ffffffaa; | |
| margin-top: 8px; | |
| } | |
| /* ───────── Annotations panel ───────── */ | |
| .legend { | |
| max-width: 393px; | |
| color: #fff; | |
| font-size: 13px; | |
| line-height: 1.55; | |
| margin: 20px auto 0; | |
| background: #00000030; | |
| padding: 16px 18px; | |
| border-radius: 14px; | |
| backdrop-filter: blur(8px); | |
| } | |
| .legend b { color: #fff; } | |
| .legend .phase { display: flex; gap: 10px; margin-bottom: 6px; } | |
| .legend .phase .t { | |
| flex: 0 0 88px; color: #ffffff90; font-variant-numeric: tabular-nums; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Island toast — Dynamic Island morph</h1> | |
| <p class="subtitle">2026-05-26 · iPhone 16 Pro (393×852) · Lapster Settings → Toast tester</p> | |
| <div class="iphone"> | |
| <div class="screen"> | |
| <div class="statusbar"> | |
| <span class="time">6:16</span> | |
| <span class="icons"> | |
| <span class="signal"></span> | |
| <span class="wifi"></span> | |
| <span class="battery"></span> | |
| </span> | |
| </div> | |
| <div class="header"> | |
| <span class="back">‹ Settings</span> | |
| <span class="title-screen">Toast tester</span> | |
| </div> | |
| <div class="section-title">Production scenarios</div> | |
| <div class="group"> | |
| <div class="row">Trigger (signal getting weak)</div> | |
| <div class="row">Recovered (signal back)</div> | |
| </div> | |
| <div class="section-title">Text-shape edge cases</div> | |
| <div class="group"> | |
| <div class="row">Title only (no body)</div> | |
| <div class="row">Very long title</div> | |
| <div class="row">Very long body</div> | |
| <div class="row">Empty body string</div> | |
| </div> | |
| <div class="section-title">Icon + tint variants</div> | |
| <div class="group"> | |
| <div class="row">Success (green ✓)</div> | |
| <div class="row">Info (blue arrow.down)</div> | |
| <div class="row">Warning (orange !)</div> | |
| <div class="row">Error (red xmark)</div> | |
| <div class="row">Bolt (Pro vibe)</div> | |
| </div> | |
| <!-- Physical Dynamic Island hardware cutout --> | |
| <div class="di"></div> | |
| <!-- Toast pill — morphs out of the DI, growing DOWN. | |
| The TOP 36pt always matches the DI footprint (stays as blank black), | |
| the content lives in the slab BELOW the DI region. --> | |
| <div class="toast" id="toast"> | |
| <div class="content"> | |
| <div class="badge"> | |
| <svg viewBox="0 0 16 16" fill="none"> | |
| <path d="M3 8.5l3 3 7-7" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/> | |
| </svg> | |
| </div> | |
| <div class="text"> | |
| <div class="title">Saved to device</div> | |
| <div class="body">Default success state.</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <button class="cta" onclick="play()">▶ Replay animation</button> | |
| <button class="cta secondary" onclick="toggleLoop()">↻ Toggle loop</button> | |
| </div> | |
| <p class="loop-hint" id="loopHint">Loop: ON</p> | |
| <div class="legend"> | |
| <div class="phase"><span class="t">0 → 250 ms</span> <span><b>Materialise.</b> Pill scales 0 → 1 from a dot at the DI's top, ending at exactly DI dimensions (124 × 36, black). At rest, the pill <i>is</i> the DI.</span></div> | |
| <div class="phase"><span class="t">330 → 880 ms</span> <span><b>Expand DOWN.</b> Width grows to fill the screen, height grows from 36 → 88 — but only downward. The top 36 px stays a flat black slab over the DI; content appears in the new 52 px slab below.</span></div> | |
| <div class="phase"><span class="t">3880 ms</span> <span><b>Linger</b> with content visible.</span></div> | |
| <div class="phase"><span class="t">3880 → 4330</span> <span><b>Collapse.</b> Width + height contract back to DI size; content fades.</span></div> | |
| <div class="phase"><span class="t">4330 → 4580</span> <span><b>Dematerialise.</b> Pill shrinks back to a dot and fades out, leaving the DI on its own.</span></div> | |
| </div> | |
| <script> | |
| const toast = document.getElementById('toast'); | |
| let looping = true; | |
| let timers = []; | |
| function clearTimers() { | |
| timers.forEach(t => clearTimeout(t)); | |
| timers = []; | |
| } | |
| function play() { | |
| clearTimers(); | |
| toast.classList.remove('materialised', 'expanded'); | |
| // Force reflow so the .remove() above takes effect before re-adding | |
| void toast.offsetWidth; | |
| // Phase 1 — materialise (pill = DI dimensions) | |
| timers.push(setTimeout(() => toast.classList.add('materialised'), 50)); | |
| // Phase 2 — expand DOWN (width + height grow, top stays at DI top) | |
| timers.push(setTimeout(() => toast.classList.add('expanded'), 380)); | |
| // Phase 3 — linger (3s default displayDuration) | |
| // Phase 4 — collapse back to DI size | |
| timers.push(setTimeout(() => toast.classList.remove('expanded'), 3880)); | |
| // Phase 5 — dematerialise | |
| timers.push(setTimeout(() => toast.classList.remove('materialised'), 4330)); | |
| if (looping) { | |
| timers.push(setTimeout(play, 5400)); | |
| } | |
| } | |
| function toggleLoop() { | |
| looping = !looping; | |
| document.getElementById('loopHint').textContent = 'Loop: ' + (looping ? 'ON' : 'OFF'); | |
| if (looping) play(); | |
| } | |
| // Autoplay | |
| play(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment