灵动岛形变

motion · island-ios · 灵感来源:iOS 16 Dynamic Island

点岛体切换形态 · 展开后可播控

复用卡

Dynamic Island Morph · 灵动岛形变 · 动效 / Motion

规格 · 术语提示词(给 AI 用)

做一个 Dynamic Island Morph(灵动岛形变):
- 紧凑⇄展开两态之间做弹簧插值(阻尼比 ≈0.78)
- 形变期间保持布局稳定,不逐帧改尺寸
- 降级:prefers-reduced-motion 下直接到位
用这些 CSS 变量:--color-accent --color-success-fg --color-text-muted --shadow-lg
灵感参考:iOS 16 Dynamic Island

参数(CSS 变量)

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

必缺状态

(无必缺状态)

整页场景

(尚未接进整页场景)

安装(一条链接)

参考(本站可核对)

调研笔记

灵动岛形变

拆解

  • 不是状态切换,是连续形变:一个弹簧驱动进度 p∈[0,1], 宽/高/圆角同时对 p 插值(126×38 r19 ⇄ 330×150 r30)
  • 内容交叉淡化必须加阈值:紧凑内容前 50% 淡出、展开内容 45% 后才淡入, 否则中间态两坨内容重叠
  • 岛体 = 硬件开孔,保持纯黑药丸;浅色页面上的高对比正是辨识度
  • 弹簧 ζ≈0.78(k=280, c=26):轻微过冲,形变才有"肉感"

实现取舍

  • 与弹簧曲线同款半隐式欧拉积分器(组件内联,~15 行,不为此抽公共模块)
  • 形变中每帧写内联样式;静止休眠不空转
  • 展开态播控真实可用:播放键控制均衡器条的 animation-play-state
  • 点击岛体切换形态,点内部控件不冒泡触发切换

关键参数

  • 紧凑 126×38 r=19;展开 330×150 r=30
  • 内容淡入淡出阈值 0.45/0.5,交叉窗口仅 0.05

实现

motion/island-ios
import { 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>
  );
}

元信息

标识
motion/island-ios
状态
done
最后提交
2026-11-01
风格轴
island-ios
依赖 token
--color-accent --color-success-fg --color-text-muted --shadow-lg
在工作台打开 源码与 notes 都在 src/components/motion/island-ios/