Skip to content

Instantly share code, notes, and snippets.

@milushov
Created May 26, 2026 18:49
Show Gist options
  • Select an option

  • Save milushov/4548b8a63a4f29b4ffa3b812d284228c to your computer and use it in GitHub Desktop.

Select an option

Save milushov/4548b8a63a4f29b4ffa3b812d284228c to your computer and use it in GitHub Desktop.
Lapster island toast — DI grows DOWN, top stays as DI
<!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