CSS Graphics Geometry
Guide
Reference
GitHub
Guide
Reference
GitHub
  • Part 0. Math foundations

    • 좌표계와 CSS pixel
    • 점, 벡터, 거리와 기본 용어
    • 각도, 라디안, atan2
  • Part 1. Transform linear algebra

    • translate, scale, rotate
    • CSS matrix(a,b,c,d,e,f)
    • 변환 순서와 transform-origin
    • local, world, screen 좌표계
    • inverse matrix와 포인터 입력
    • 좌표와 transform 디버깅
  • Part 2. Infinite canvas math and architecture

    • viewport와 camera 모델
    • cursor anchored zoom
    • ruler와 tick 계산
    • 주기 함수와 grid step
    • content, overlay, controls layer
    • scene graph와 nested transform
  • Part 3. Editor tool math

    • Pointer Events와 드래그
    • DOMRect와 getBoundingClientRect
    • hit testing과 bounding box
    • selection rectangle과 marquee
    • selection bounds와 handles
    • resize와 rotation handle
    • snapping과 smart guides
    • group, frame, clipping
  • Part 4. CSS graphics properties as math

    • CSS box로 도형 만들기
    • clip-path, radius, shadow
    • 선형보간과 linear-gradient
    • 거리 함수와 radial/conic-gradient
    • stacking context와 합성
    • layout, paint, composite
  • Part 5. Editor state and persistence

    • 레이어 모델과 z-index
    • 이동, 복제, 삭제, 잠금
    • undo/redo command model
    • JSON export/import
  • Part 6. SVG overlay and vector editing

    • SVG viewBox와 좌표계
    • getScreenCTM과 inverse
    • SVG pointer-events와 stroke hit testing
    • cubic bezier path editing
    • mask, clipPath, marker
  • Part 7. Figma to CSS translation

    • Figma node와 CSS DOM 모델
    • Frame constraints와 Auto Layout
    • Fills, strokes, effects를 CSS로
    • Vector와 text 변환
    • gradient 밖의 CSS 수학
    • Figma gradient를 CSS gradient로 변환하기
    • 수학이 필요한 Figma to CSS 속성들
    • Figma VectorNetwork 정리
  • Appendix A. Canvas renderer transition

    • Canvas로 넘어가는 기준
  • Appendix B. Motion and timing

    • 모션 수학과 timing 함수
    • Keyframe timeline 엔진
    • Motion path 수학
    • Bezier curve 길이 구하기

Keyframe timeline 엔진

진짜 마지막으로 keyframe 엔진을 만들어봅시다.

048에서는 “시간을 progress로 바꾸고 easing으로 구부린 다음 값을 보간한다”는 기본 구조를 봤습니다. 이번에는 한 단계 더 갑니다. 하나의 시작값과 끝값이 아니라, 여러 node의 여러 property keyframe을 timeline 위에 놓고 원하는 시간으로 바로 이동할 수 있게 만들겠습니다.

node tracks -> timeline -> sample(time) -> render(scene state)

이게 keyframe 엔진의 핵심입니다.

1. keyframe은 node의 property에 붙은 값이다

처음에는 keyframe을 “특정 시간의 전체 상태”라고 생각하기 쉽습니다.

const keyframes = [
  { time: 0, x: 40, y: 160, scale: 1, rotate: 0, opacity: 0.4 },
  { time: 700, x: 160, y: 70, scale: 1.2, rotate: 18, opacity: 1 },
  { time: 1800, x: 360, y: 190, scale: 0.9, rotate: 220, opacity: 0.8 },
  { time: 3000, x: 470, y: 120, scale: 1, rotate: 360, opacity: 1 }
];

이 방식도 단일 오브젝트 예제에는 좋습니다. 하지만 실제 에디터에서는 보통 이렇게만 저장하지 않습니다. 노드마다 움직이는 속성이 다르고, keyframe이 찍히는 시점도 다르기 때문입니다.

card:
  x, y, scale, rotate, opacity, color가 움직임

badge:
  x, y, scale, opacity만 움직임

cursor:
  x, y, opacity, rotate만 움직임

그래서 더 실제적인 모델은 node별 track 구조입니다.

const timeline = {
  duration: 3000,
  nodes: [
    {
      id: "card",
      defaults: { x: 52, y: 162, scale: 0.86, rotate: 0, opacity: 0.48 },
      tracks: {
        x: [
          { time: 0, value: 52, easingToNext: "easeOut" },
          { time: 620, value: 164, easingToNext: "easeInOut" },
          { time: 1540, value: 332 }
        ],
        opacity: [
          { time: 0, value: 0.48, easingToNext: "easeOut" },
          { time: 420, value: 1 }
        ]
      }
    },
    {
      id: "badge",
      defaults: { x: 92, y: 78, scale: 0.5, opacity: 0 },
      tracks: {
        opacity: [
          { time: 650, value: 0, easingToNext: "easeOut" },
          { time: 960, value: 1 }
        ]
      }
    }
  ]
};

이 구조의 장점은 큽니다.

1. 노드마다 다른 속성만 keyframe으로 저장할 수 있다.
2. 속성마다 keyframe 시점이 달라도 된다.
3. keyframe이 없는 속성은 defaults를 쓰면 된다.
4. timeline UI에서 node row와 property track으로 확장하기 쉽다.

즉 keyframe은 “전체 object snapshot”일 수도 있지만, editor timeline에서는 보통 node.property의 시간별 값으로 보는 편이 좋습니다.

2. 현재 시간이 주어지면 track별 segment를 찾는다

엔진은 현재 시간이 주어지면 각 track에서 양옆 keyframe을 찾습니다.

left.time <= currentTime <= right.time

그다음 구간 progress를 계산합니다.

segmentProgress =
  (currentTime - left.time) / (right.time - left.time)

그리고 easing을 적용합니다.

eased = easing(segmentProgress)

마지막으로 각 property를 보간합니다.

value = interpolate(left.value, right.value, eased)

이 과정을 모든 node와 모든 property track에 대해 반복합니다.

sample(time):
  for each node:
    for each property track:
      sampleTrack(property, frames, time)
    merge with defaults

이게 sample(time)입니다. 재생도, 정지도, scrub도, jump도 결국 이 함수 하나로 돌아옵니다. 엔진계의 밥솥 같은 친구입니다. 여러 메뉴가 있어도 밥은 여기서 나옵니다.

3. sample 함수가 source of truth다

keyframe 엔진에서 중요한 건 “현재 DOM이 어디 있지?”를 기준으로 삼지 않는 겁니다.

나쁜 흐름:
DOM 위치를 읽는다
조금 더 움직인다
다시 DOM에 쓴다

좋은 흐름:
currentTime을 정한다
sample(currentTime)으로 model state를 만든다
render(state)를 호출한다

샘플러는 대략 이렇게 생깁니다.

function sample(time) {
  return timeline.nodes.map((node) => {
    const state = { ...node.defaults };

    for (const [property, frames] of Object.entries(node.tracks)) {
      state[property] = sampleTrack(property, frames, time, node.defaults[property]);
    }

    return { id: node.id, ...state };
  });
}

function sampleTrack(property, frames, time, fallback) {
  if (!frames?.length) return fallback;

  const segment = findSegment(frames, time);
  const p = normalize(time, segment.left.time, segment.right.time);
  const eased = easing(segment.left.easingToNext)(p);

  return interpolateProperty(
    property,
    segment.left.value,
    segment.right.value,
    eased
  );
}

재생 중에도 current time만 증가시킵니다.

time = now - playStartTime + startOffset
state = sample(time)
render(state)

원하는 위치로 바로 이동할 때도 같습니다.

time = clickedTimelineX / timelineWidth * duration
state = sample(time)
render(state)

한 함수로 모든 길이 로마로 통합니다. 아니, 로마는 좀 멀고요. 아무튼 다 sample로 갑니다.

4. play, stop, seek는 clock 제어다

play는 시간을 흐르게 합니다.

function play() {
  playing = true;
  playStart = performance.now();
  startOffset = currentTime;
  requestAnimationFrame(tick);
}

stop은 시간을 멈춥니다. 상태를 날리지 않습니다.

function stop() {
  playing = false;
  cancelAnimationFrame(frameId);
}

seek는 시간을 직접 지정합니다.

function seek(time) {
  currentTime = clamp(time, 0, duration);
  render(sample(currentTime));
}

여기서 중요한 점은 stop과 seek가 animation을 “망가뜨리는” 게 아니라는 겁니다. 그냥 clock을 제어할 뿐입니다. keyframe data는 그대로 있고, 현재 time만 바뀝니다.

그래서 timeline UI는 엔진과 잘 맞습니다.

playhead position <-> currentTime
marker position   <-> keyframe.time
preview state     <-> sample(currentTime)

5. 구간 easing은 왼쪽 keyframe에 둔다

보통 keyframe 사이의 easing은 구간에 붙습니다. 구현에서는 왼쪽 keyframe에 easingToNext를 두면 편합니다.

{
  time: 700,
  x: 160,
  y: 70,
  easingToNext: "easeInOut"
}

이 말은:

700ms -> 다음 keyframe까지 easeInOut으로 간다

구간별 easing을 두면 같은 animation 안에서도 리듬이 달라집니다.

0ms -> 700ms:
  easeOut, 빠르게 출발해서 부드럽게 도착

700ms -> 1800ms:
  easeInOut, 중간에서 속도가 붙음

1800ms -> 3000ms:
  linear, 일정하게 마무리

이 구조가 있으면 prototype transition, timeline editor, interaction recording 같은 기능으로 확장하기 쉽습니다.

6. timeline UI가 해야 하는 일

timeline UI는 어려워 보이지만 기본 역할은 네 가지입니다.

1. keyframe marker를 time 비율 위치에 표시한다.
2. playhead를 currentTime 위치에 표시한다.
3. 사용자가 timeline을 클릭하면 seek한다.
4. 사용자가 scrub하면 계속 seek한다.

수식은 단순합니다.

x = time / duration * timelineWidth
time = x / timelineWidth * duration

여기서도 좌표 변환입니다. 세상은 자꾸 좌표 변환으로 돌아옵니다. 이제 놀랍지도 않습니다.

조금 더 제대로 만들면 timeline은 이런 계층을 갖습니다.

timeline
  node row: card
    property track: x
    property track: y
    property track: opacity
  node row: badge
    property track: scale
    property track: opacity
  node row: cursor
    property track: x
    property track: y

이번 데모는 복잡한 property row까지 그리지는 않고, node별 keyframe marker를 서로 다른 높이에 표시합니다. marker 색도 node마다 다릅니다. 작은 데모지만 데이터 구조는 실제 timeline editor 쪽에 가깝습니다.

7. editor에서 keyframe 엔진을 쓰는 곳

Figma 같은 에디터 본체는 주로 정적 디자인 도구지만, keyframe 엔진은 여러 곳에서 쓸 수 있습니다.

prototype transition preview
component interaction preview
timeline plugin
motion design tool
canvas camera fly-to
selection focus animation
recorded interaction playback

특히 editor에서 중요한 건 keyframe이 DOM이 아니라 model에 걸려야 한다는 점입니다.

node.position.x keyframes
node.rotation keyframes
node.opacity keyframes
camera.zoom keyframes
camera.position keyframes

DOM은 그 결과를 보여주는 projection입니다. 이 원칙을 지키면 CSS renderer, SVG renderer, Canvas renderer를 바꿔도 motion data는 보존됩니다.

8. CSS keyframes와 JS keyframe engine

CSS @keyframes도 훌륭한 keyframe 엔진입니다.

@keyframes pop {
  0% { transform: scale(0.9); opacity: 0; }
  60% { transform: scale(1.04); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}

단순한 UI feedback은 CSS로 충분합니다.

button hover
popover enter/exit
loading pulse
selection outline fade

하지만 직접 scrub하거나, timeline marker를 편집하거나, editor model과 동기화해야 한다면 JS keyframe engine이 낫습니다.

CSS keyframes:
  선언형, 간단, 브라우저가 잘 최적화

JS keyframe engine:
  seek/scrub/edit/serialize에 강함

이번 레슨의 데모는 JS keyframe engine입니다. 그래서 Play, Stop, Jump, timeline click, slider seek가 모두 같은 sample(time)으로 처리됩니다.

마무리

keyframe timeline 엔진의 핵심은 이 네 줄입니다.

currentTime을 정한다.
currentTime이 속한 keyframe segment를 찾는다.
segment progress를 easing으로 구부린다.
각 property를 보간해서 render한다.

이 구조를 잡으면 play/stop은 clock 문제이고, seek는 time 값을 바꾸는 문제이고, timeline UI는 time과 x좌표를 변환하는 문제입니다.

끝까지 와서 보니 또 좌표 변환입니다. 이 강의의 결론이 약간 집착처럼 보일 수 있는데, 그래픽 툴은 정말 그렇습니다. 공간 좌표를 다루다가, 마지막에는 시간 좌표까지 다루게 됩니다.

Edit this page
최근 수정: 26. 5. 14. PM 5:45
Contributors: easylogic
Prev
모션 수학과 timing 함수
Next
Motion path 수학