Morph: Biến đổi không cần xóa đi làm lại

Fade out rồi fade in là cách xử lý chuyển trạng thái an toàn nhất — và cũng kém thông tin nhất. Người dùng không biết object cũ và object mới có liên quan gì nhau. Morph giải quyết điều đó: cùng một “thực thể” nhưng mọi thuộc tính về nó đang thay đổi theo thời gian thực.

Circle

Click Next shape — 24 dots rearrange thành Circle, Triangle, Grid

Tại sao morph hoạt động với não người

Khi một object biến mất rồi một object khác xuất hiện, não người xử lý đó là hai object riêng biệt. Khi cùng một object di chuyển và thay đổi, não theo dõi nó như một thực thể liên tục — object permanence.

Morph khai thác object permanence để truyền thông tin: “đây vẫn là thứ đó, chỉ là ở trạng thái khác.” Điều này đặc biệt có giá trị khi trạng thái mới có quan hệ ngữ nghĩa với trạng thái cũ — như play/pause, expanded/collapsed, hay bản thân bài này: 12 chòm sao từ cùng một tập hợp sao.

Cơ chế cốt lõi: interpolation

Morph ở mức đơn giản nhất là lerp (linear interpolation) giữa hai tập tọa độ:

// Mỗi frame trong animation loop
const x = source.x + (target.x - source.x) * progress;
const y = source.y + (target.y - source.y) * progress;

Trong đó progress là một giá trị từ 0 đến 1 chạy theo thời gian. Dùng easing để progress không tuyến tính:

function easeInOut(t: number) {
  return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}

const progress = easeInOut(Math.min(1, elapsed / duration));

easeInOut làm object khởi động chậm, tăng tốc ở giữa, rồi chậm lại khi đến đích — giống vật lý thực tế hơn nhiều so với linear.

Vấn đề số lượng không khớp

Morph dễ khi số element giữ nguyên. Khó hơn khi trạng thái A có 4 dots và trạng thái B có 9 dots. Có ba cách xử lý:

Spawn từ điểm gần nhất: dot mới xuất hiện tại vị trí một dot hiện có, rồi di chuyển đến đích. Trông tự nhiên vì dot “tách ra” từ cluster.

// Dot thừa: spawn tại dot hiện có gần nhất
for (let i = nCurr; i < nNext; i++) {
  const src = dots[i % nCurr]; // dùng modulo để chọn nguồn
  dots.push({ sx: src.x, sy: src.y, tx: targets[i].x, ty: targets[i].y });
}

Thu về tâm: dot dư thừa từ trạng thái A di chuyển vào tâm rồi fade out. Ngược lại với spawn — dot “hòa tan” vào nhau thay vì tách ra.

Cross-fade từng phần: với số chênh lệch lớn, fade out toàn bộ group cũ và fade in group mới — hybrid giữa morph và replace.

State machine cho morph phức tạp

Animation đơn giản chỉ cần một flag isAnimating. Morph nhiều trạng thái cần state machine rõ ràng để tránh race condition:

type Phase = 'idle' | 'fade_out' | 'morph' | 'fade_in';

let phase: Phase = 'idle';
let phaseStart = 0;

function frame(now: number) {
  const t = now - phaseStart;

  if (phase === 'fade_out' && t > FADE_DUR) {
    prepareTargets();      // setup targets cho trạng thái mới
    phase = 'morph';
    phaseStart = now;
    return;                // return để frame tiếp theo bắt đầu với t = 0
  }

  if (phase === 'morph' && t > MORPH_DUR) {
    snapToTargets();       // lock vào vị trí cuối
    phase = 'idle';
    phaseStart = now;
    return;
  }

  // draw based on current phase...
}

Lưu ý quan trọng: return ngay sau khi đổi phase. Nếu không, drawing code ngay bên dưới sẽ chạy với t cũ (trước khi reset phaseStart) — khiến animation nhảy đột ngột một frame.

Alpha morph: hơn cả vị trí

Morph không chỉ là vị trí. Opacity, scale, màu sắc đều có thể interpolate đồng thời. Dot mới xuất hiện bằng cách fade in trong khi di chuyển:

// Dot mới (born): alpha từ 0 → 1 trong quá trình morph
if (dot.born) {
  alpha = easeInOut(morphProgress);
}

// Dot thừa (dying): alpha từ 1 → 0
if (dot.dying) {
  alpha = 1 - easeInOut(morphProgress);
}

Dùng cùng một easeInOut cho cả alpha và position để chúng đồng bộ — dot mờ dần vào đúng lúc nó đến nơi, dot mới sáng dần lên từ điểm xuất phát.

Khi nào không dùng morph

Morph phù hợp khi có sự liên tục ngữ nghĩa: cùng một thứ ở trạng thái khác nhau.

Không phù hợp khi:

  • Nội dung hoàn toàn không liên quan (tab A → tab B khác chủ đề)
  • Số lượng element chênh lệch quá lớn (3 dots → 50 dots)
  • Người dùng cần biết rõ “cái cũ đã biến mất, cái mới là khác hoàn toàn”

Trong những trường hợp đó, fade hoặc slide với spatial direction cho nhiều thông tin hơn.

Ví dụ thực tế

Play → Pause icon: morph các đường thẳng của nút play (tam giác) thành hai thanh đứng của pause. SVG path morphing dùng cùng số điểm anchor.

Search → Close: icon kính lúp morph thành dấu X — vòng tròn thu nhỏ, tay cầm xoay thành nét chéo thứ hai.

Chòm sao hoàng đạo: 12 chòm sao khác nhau dùng cùng một tập “sao” — khi chuyển cung, các sao di chuyển đến vị trí mới thay vì biến mất. Người dùng cảm nhận đây là một hệ thống liên tục, không phải 12 ảnh riêng lẻ.