motion · island-ios · 灵感来源:iOS 16 Dynamic Island
点岛体切换形态 · 展开后可播控
Dynamic Island Morph · 灵动岛形变 · 动效 / Motion
做一个 Dynamic Island Morph(灵动岛形变):
- 紧凑⇄展开两态之间做弹簧插值(阻尼比 ≈0.78)
- 形变期间保持布局稳定,不逐帧改尺寸
- 降级:prefers-reduced-motion 下直接到位
用这些 CSS 变量:--color-accent --color-success-fg --color-text-muted --shadow-lg
灵感参考:iOS 16 Dynamic Island--color-accent --color-success-fg --color-text-muted --shadow-lg
(无必缺状态)
(尚未接进整页场景)
/r/motion--island-ios.json
免外壳的 markdown 版:/llms/components/motion--island-ios.mdimport { useEffect, useRef, useState } from 'react';
import { prefersReducedMotion } from '~/lib/motion';
// 形态插值端点:紧凑 ⇄ 展开
const COMPACT = { w: 126, h: 38, r: 19 };
const EXPANDED = { w: 330, h: 150, r: 30 };
const K = 280; // ζ≈0.78:轻微过冲,形变有"肉感"
const C = 26;
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
export function IslandMorph() {
const [expanded, setExpanded] = useState(false);
const [playing, setPlaying] = useState(true);
const [, setTick] = useState(0);
const sim = useRef({ p: 0, v: 0 });
const targetRef = useRef(0);
const raf = useRef(0);
const sleeping = useRef(true);
const wake = () => {
if (!sleeping.current) return;
sleeping.current = false;
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
const s = sim.current;
// 降级:不做弹簧插值,直接到位(动效与降级同批交付)
if (prefersReducedMotion()) {
s.p = targetRef.current;
s.v = 0;
sleeping.current = true;
setTick((t) => t + 1);
return;
}
s.v += (-K * (s.p - targetRef.current) - C * s.v) * dt;
s.p += s.v * dt;
setTick((t) => t + 1);
if (Math.abs(s.p - targetRef.current) < 0.0005 && Math.abs(s.v) < 0.0005) {
s.p = targetRef.current;
s.v = 0;
sleeping.current = true;
setTick((t) => t + 1);
return;
}
raf.current = requestAnimationFrame(step);
};
raf.current = requestAnimationFrame(step);
};
useEffect(() => () => cancelAnimationFrame(raf.current), []);
const toggle = () => {
const next = !expanded;
setExpanded(next);
targetRef.current = next ? 1 : 0;
wake();
};
const p = clamp01(sim.current.p);
const w = lerp(COMPACT.w, EXPANDED.w, p);
const h = lerp(COMPACT.h, EXPANDED.h, p);
const r = lerp(COMPACT.r, EXPANDED.r, p);
// 内容交叉淡化:错开阈值,避免中间态重叠
const compactOpacity = clamp01(1 - p * 2);
const expandedOpacity = clamp01((p - 0.45) / 0.35);
return (
<div
role="button"
aria-expanded={expanded}
aria-label={expanded ? '收起灵动岛' : '展开灵动岛'}
onClick={toggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
}}
tabIndex={0}
className="relative bg-black cursor-pointer select-none overflow-hidden shadow-[var(--shadow-lg)]"
style={{ width: w, height: h, borderRadius: r }}
>
{/* 紧凑态:活动指示 + 微波形 */}
<div
className="absolute inset-0 flex items-center justify-between px-4"
style={{ opacity: compactOpacity }}
aria-hidden={p > 0.5}
>
<span className="size-2.5 rounded-full bg-[var(--color-success-fg)]" />
<div className="flex items-end gap-[3px] h-4">
{[0, 1, 2, 3].map((i) => (
<span
key={i}
className="island-eq w-[3px] rounded-full bg-[var(--color-accent)]"
style={{ animationDelay: `${i * 0.15}s`, animationPlayState: playing ? 'running' : 'paused' }}
/>
))}
</div>
</div>
{/* 展开态:播放器 */}
<div
className="absolute inset-0 p-4 flex flex-col justify-between"
style={{ opacity: expandedOpacity }}
aria-hidden={p < 0.45}
>
<div className="flex items-center gap-3">
<div className="size-11 rounded-lg bg-gradient-to-br from-[var(--color-accent)] to-[#38bdf8] shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-white text-sm font-medium truncate">Glass Reflection</p>
<p className="text-white/40 text-xs truncate">UI Echo · Liquid Sessions</p>
</div>
<div className="flex items-end gap-[3px] h-5">
{[0, 1, 2, 3].map((i) => (
<span
key={i}
className="island-eq w-[3px] rounded-full bg-[var(--color-accent)]"
style={{ animationDelay: `${i * 0.15}s`, animationPlayState: playing ? 'running' : 'paused' }}
/>
))}
</div>
</div>
<div>
{/* 进度条 */}
<div className="h-1 rounded-full bg-white/15 mb-3">
<div className="h-full w-[38%] rounded-full bg-white/80" />
</div>
<div className="flex items-center justify-center gap-6" onClick={(e) => e.stopPropagation()}>
<span className="text-white/50 text-sm cursor-pointer hover:text-white transition-colors" aria-hidden="true">⏮</span>
<button
type="button"
onClick={() => setPlaying((v) => !v)}
aria-label={playing ? '暂停' : '播放'}
className="size-9 rounded-full bg-white text-black flex items-center justify-center text-sm press-feedback press-hover transition-transform cursor-pointer"
>
{playing ? '⏸' : '▶'}
</button>
<span className="text-white/50 text-sm cursor-pointer hover:text-white transition-colors" aria-hidden="true">⏭</span>
</div>
</div>
</div>
</div>
);
}
src/components/motion/island-ios/