# 灵动岛形变（Dynamic Island Morph）

- 类型 / 材质：`motion` / `island-ios`（岛）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：iOS 16 Dynamic Island
- 依据标准：`/design#motion` ｜ 组件页：`/showcase/component/motion/island-ios` ｜ 条目：`/r/motion--island-ios.json`
- 必缺状态：（无必缺状态）
- 整页场景：尚未接入

## 规格

- 紧凑⇄展开两态之间做弹簧插值（阻尼比 ≈0.78）
- 形变期间保持布局稳定，不逐帧改尺寸
- 降级：prefers-reduced-motion 下直接到位

## 参数（CSS 变量）

`--color-accent` `--color-success-fg` `--color-text-muted` `--shadow-lg`

## Props 契约

这支不接收 props——自包含演示组件。

## 提示词（可直接粘给智能体）

```
复现「灵动岛形变」— Dynamic Island Morph（动效 / Motion）
灵感来源：iOS 16 Dynamic Island
规格：
- 紧凑⇄展开两态之间做弹簧插值（阻尼比 ≈0.78）
- 形变期间保持布局稳定，不逐帧改尺寸
- 降级：prefers-reduced-motion 下直接到位
参数（CSS 变量）：--color-accent --color-success-fg --color-text-muted --shadow-lg
必缺状态：（无必缺状态）
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/motion/island-ios · 标准 motion /design#motion · 调研笔记 /showcase/component/motion/island-ios#notes
```

## 调研笔记

import { useEffect, useRef, useState } from 'react';
import { prefersReducedMotion } from '@/lib/ui-echo/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>
  );
}
