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 길이 구하기

Figma gradient를 CSS gradient로 변환하기

이번에는 Figma의 gradient fill을 CSS gradient 문자열로 바꾸는 공식을 보겠습니다.

겉으로 보면 “Figma gradient니까 CSS gradient로 바꾸면 되지 않나?” 싶습니다. 네, 대체로 됩니다. 그런데 여기서 한 번 미끄러지기 좋습니다. Figma는 gradient를 handle 중심 모델로 다루고, CSS는 gradient 종류마다 조금 다른 문법 중심 모델로 다룹니다.

Figma 쪽 데이터는 보통 이렇게 생각하면 편합니다. 공식 문서의 widget 계열 GradientPaint는 gradientHandlePositions 세 개와 gradientStops를 가집니다. plugin API의 paint는 gradientTransform과 gradientStops를 가집니다. 둘 다 결국 “오브젝트 박스 안에서 gradient 좌표계를 어디에 놓을 것인가”를 말합니다.

Figma gradient의 세 점

Figma의 gradientHandlePositions는 normalized object space의 점입니다.

object top-left     = (0, 0)
object bottom-right = (1, 1)

세 handle은 이렇게 읽을 수 있습니다.

H0 = start 또는 center
H1 = end 또는 첫 번째 radius direction
H2 = width 또는 두 번째 radius direction

픽셀 좌표로 바꾸려면 오브젝트 크기를 곱합니다.

P0 = (H0.x * width, H0.y * height)
P1 = (H1.x * width, H1.y * height)
P2 = (H2.x * width, H2.y * height)

공식 문서에서 gradientStops.position은 0..1 범위입니다. CSS로 내보낼 때는 보통 % 또는 deg로 바꿉니다.

cssPercent = stop.position * 100%
cssAngleStop = stop.position * 360deg

여기까지는 얌전합니다. 진짜 문제는 linear gradient입니다.

Figma gradient handles to CSS gradient diagramFigma normalized handlesH0 startH1 endH2 widthCSS projectionbox centerCSS line is extendedP(s) = P0 + (P1 - P0) * scssStop = (dot(P(s) - center, axis) + L / 2) / L
Figma gradient handle은 normalized object space의 점이고, CSS gradient stop은 CSS gradient line 또는 각도/반지름 좌표계 위의 위치입니다. 그래서 변환은 좌표 재투영 문제입니다.

gradientTransform을 좌표 함수로 읽기

Figma plugin API에서 gradient paint를 읽으면 보통 이런 모양을 만납니다.

type GradientPaint = {
  type:
    | "GRADIENT_LINEAR"
    | "GRADIENT_RADIAL"
    | "GRADIENT_ANGULAR"
    | "GRADIENT_DIAMOND";
  gradientTransform: [
    [number, number, number],
    [number, number, number]
  ];
  gradientStops: Array<{
    position: number;
    color: { r: number; g: number; b: number; a: number };
  }>;
  opacity?: number;
};

gradientTransform은 2x3 affine transform입니다. Figma의 Transform 문서는 이것을 3x3 행렬의 위쪽 두 줄로 봅니다. 아래 줄은 암묵적으로 [0, 0, 1]입니다.

T = [
  [a, c, e],
  [b, d, f]
]

full matrix =
[
  [a, c, e],
  [b, d, f],
  [0, 0, 1]
]

plugin API의 gradient paint를 CSS로 변환할 때는 이 행렬을 오브젝트 normalized 좌표에서 gradient 좌표로 가는 함수로 읽는 편이 실제 getCSSAsync() 결과와 맞습니다.

G(x, y) = (
  a * x + c * y + e,
  b * x + d * y + f
)

여기서 (x, y)는 오브젝트 박스 안의 normalized 좌표입니다.

top-left     = (0, 0)
bottom-right = (1, 1)

linear gradient에서는 첫 번째 성분이 색상 stop 좌표입니다.

u(x, y) = a * x + c * y + e

이 u 값이 0이면 첫 번째 stop, 1이면 마지막 stop 쪽입니다. 아주 중요합니다. gradientTransform을 곧바로 H0 = T(0,0), H1 = T(1,0)처럼 handle 위치로 읽으면 실제 Figma CSS export와 어긋날 수 있습니다. plugin API의 gradientTransform은 “handle 세 점”이라기보다 “gradient 좌표계를 계산하는 affine 함수”로 먼저 읽어야 합니다.

만약 화면에 Figma식 handle을 그리고 싶다면 그때는 역변환을 사용합니다.

handle0 = G^-1(0, 0)
handle1 = G^-1(1, 0)
handle2 = G^-1(0, 1)

2x3 transform의 역변환은 이렇게 구합니다.

G = [
  [a, c, e],
  [b, d, f]
]

det = a * d - b * c

G^-1 = [
  [ d / det, -c / det, (c*f - d*e) / det ],
  [-b / det,  a / det, (b*e - a*f) / det ]
]

그리고 점 하나를 역변환에 넣는 공식은 그대로 affine transform입니다.

G^-1(u, v) = (
  ia * u + ic * v + ie,
  ib * u + id * v + if
)

이렇게 얻은 handle은 normalized object space입니다. 그래서 화면에 그리려면 width/height를 곱합니다.

screenHandle = (
  handle.x * objectWidth,
  handle.y * objectHeight
)

여기서 handle이 0..1 밖으로 나갈 수 있습니다. 이건 오류가 아닙니다. Figma gradient handle도 오브젝트 바깥에 놓일 수 있습니다. 편집기 overlay를 만들 때는 canvas나 SVG overlay가 object bounds 밖까지 그릴 수 있어야 합니다. 안 그러면 “핸들이 사라졌어요!” 하고 학생들이 웅성웅성합니다. 교수님도 같이 웅성웅성합니다.

하지만 CSS 변환만 목적이라면 linear gradient는 역변환까지 가지 않아도 됩니다. 첫 번째 row [a, c, e]만으로 angle과 stop 위치를 계산할 수 있습니다.

변환 파이프라인은 이렇게 잡으면 됩니다.

gradientTransform
-> gradient coordinate function G(x, y)
-> CSS gradient angle / line length
-> CSS stop positions
-> CSS string

이 구조가 좋은 이유는 간단합니다. gradientStops는 gradient 좌표계의 값이고, CSS stop은 CSS gradient line 위의 값입니다. 그러니 중간에서 “어떤 좌표 함수가 stop 값을 만드는가?”를 먼저 잡아야 합니다. 변환기도 마음의 평화를 얻습니다. 잠깐이지만요.

gradientStops 변환하기

gradientStops는 색과 위치의 배열입니다.

const stops = paint.gradientStops;

각 stop은 두 가지를 가집니다.

position: 0..1
color: RGBA, 각 채널은 보통 0..1

CSS 색상으로 바꿀 때는 RGB 채널을 0..255로 스케일합니다.

r8 = round(color.r * 255)
g8 = round(color.g * 255)
b8 = round(color.b * 255)
a  = color.a * paint.opacity

그러면 CSS 색상은 이렇게 만들 수 있습니다.

rgb(246 200 95 / 0.82)

또는 호환성을 더 넓게 잡고 싶으면 이렇게 직렬화해도 됩니다.

rgba(246, 200, 95, 0.82)

주의할 점은 paint.opacity입니다. Figma paint 자체에 opacity가 있고, stop color에도 alpha가 있을 수 있습니다. 둘 다 있다면 보통 곱해서 최종 alpha를 만듭니다.

finalAlpha = stop.color.a * (paint.opacity ?? 1)

stop 위치는 gradient 종류에 따라 다르게 직렬화합니다.

linear:  stop.position을 CSS line으로 재투영한 뒤 %
radial:  보통 stop.position * 100%
angular: stop.position * 360deg
diamond: CSS 직접 대응 없음, 별도 전략 필요

정렬도 한 번 해주는 편이 좋습니다.

const sortedStops = [...paint.gradientStops].sort(
  (a, b) => a.position - b.position
);

Figma에서 stop은 대체로 정렬되어 오지만, export 함수는 입력을 믿고 잠들면 안 됩니다. 입력은 언젠가 늦은 밤에 이상한 얼굴로 찾아옵니다.

실제 변환 함수 모양

중간 모델을 만든다면 이런 형태가 다루기 좋습니다.

function linearFunctionFromGradientTransform(transform) {
  const [[a, c, e], [b, d, f]] = transform;

  return { a, c, e };
}

function toCssColor(stop, paintOpacity = 1) {
  const r = Math.round(stop.color.r * 255);
  const g = Math.round(stop.color.g * 255);
  const b = Math.round(stop.color.b * 255);
  const a = stop.color.a * paintOpacity;

  return `rgb(${r} ${g} ${b} / ${a.toFixed(3)})`;
}

그 다음 gradient type별로 나눕니다.

const stops = paint.gradientStops
  .slice()
  .sort((a, b) => a.position - b.position);

switch (paint.type) {
  case "GRADIENT_LINEAR":
    return linearGradientToCss(paint.gradientTransform, stops, box);
  case "GRADIENT_RADIAL":
    return radialGradientToCss(paint.gradientTransform, stops, box);
  case "GRADIENT_ANGULAR":
    return angularGradientToCss(paint.gradientTransform, stops, box);
  case "GRADIENT_DIAMOND":
    return fallbackOrAssetExport(paint);
}

이때 box는 Figma node의 실제 렌더링 크기입니다.

box = { width, height }

Figma transform은 normalized object space 기준입니다. 그래서 CSS px 반지름이나 투영 길이를 계산할 때는 node 크기 또는 적어도 normalized box의 aspect ratio가 필요합니다. width/height 없이 px 값을 만들려고 하면, 그건 지도 없이 지하철 환승하는 겁니다. 자신감은 생기지만 결과가 이상해집니다.

linear-gradient 변환: stop을 다시 투영한다

Figma linear gradient에서 색상 좌표는 첫 번째 row로 계산됩니다.

u(x, y) = a * x + c * y + e

여기서 gradient 방향 벡터는 (a, c)입니다. CSS angle은 이 벡터에서 얻습니다.

len = length(a, c)
axis = (a / len, c / len)
angle = 90deg + atan2(c, a)

CSS linear-gradient(angle, ...)의 0%..100%는 Figma의 u=0..1과 같은 선분이 아닙니다. CSS는 박스 중심을 지나는 gradient line을 만들고, 박스 전체를 덮을 만큼 선을 늘립니다.

L = width * abs(axis.x) + height * abs(axis.y)
cssLineStart = boxCenter - axis * L / 2
cssLineEnd   = boxCenter + axis * L / 2

normalized box를 width = 1, height = 1로 보면 CSS stop 변환은 이렇게 쓸 수 있습니다.

boxCenter = (0.5, 0.5)
centerProjection = dot(boxCenter, axis)
cssStop(s) = (s - e) / (len * L) + 0.5 - centerProjection / L

최종 CSS stop은 이렇게 됩니다.

color stop = color cssStop * 100%

이걸 안 하고 s * 100%를 그대로 넣으면, Figma의 gradient coordinate와 CSS gradient line이 정확히 같은 경우에만 맞습니다. 대부분은 슬쩍 어긋납니다. 눈으로 보면 “거의 맞는데 뭔가 묘하게 다름”이 됩니다. 아주 얄미운 종류의 버그죠.

실제 Figma 값으로 검산하기

질문에서 준 Figma 값으로 확인해봅시다.

gradientTransform = [
  [ 0.6352941394,  0.1737142950, -0.1474285722 ],
  [-0.1737142950,  0.2321066707,  0.4848025441 ]
]

linear 변환에는 첫 번째 row를 씁니다.

a = 0.6352941394
c = 0.1737142950
e = -0.1474285722

먼저 방향과 CSS angle을 계산합니다.

len = sqrt(a^2 + c^2)
    = 0.6586162007

axis = (a / len, c / len)
     = (0.9645892991, 0.2637564865)

angle = 90deg + atan2(c, a)
      = 105.293076deg

getCSSAsync()가 105deg로 반올림한 것과 맞습니다.

이제 CSS gradient line 길이를 구합니다. normalized box 기준입니다.

L = abs(axis.x) + abs(axis.y)
  = 1.2283457855

box 중심 투영값은 이렇게 됩니다.

center = (0.5, 0.5)
centerProjection = dot(center, axis)
                 = 0.6141728928

stop 0과 stop 1을 CSS stop으로 변환합니다.

cssStop(s) = (s - e) / (len * L) + 0.5 - centerProjection / L

cssStop(0) = 0.1822336652 = 18.22%
cssStop(1) = 1.4183147213 = 141.83%

그래서 최종 CSS는 이렇게 됩니다.

linear-gradient(
  105deg,
  rgba(0, 0, 0, 0.20) 18.22%,
  rgba(102, 102, 102, 0.20) 141.83%
)

질문에서 준 getCSSAsync() 결과와 숫자가 맞습니다. 여기서 141.83%가 100%를 넘는 것도 정상입니다. CSS gradient stop은 박스 안에 꼭 갇히지 않습니다. Figma의 gradient 좌표계가 CSS gradient line보다 길게 놓이면 stop이 100% 밖으로 나갑니다. 괜찮습니다. 오히려 이게 맞는 신호입니다.

이번에는 같은 값으로 handle도 복원해봅시다.

det = a * d - b * c
    = 0.1776326639

G^-1 = [
  [ 1.3066666096, -0.9779411691,  0.6667483593 ],
  [ 0.9779411691,  3.5764488655, -1.5896950387 ]
]

세 handle은 이렇게 나옵니다.

handle0 = G^-1(0, 0)
        = (0.6667483593, -1.5896950387)

handle1 = G^-1(1, 0)
        = (1.9734149690, -0.6117538696)

handle2 = G^-1(0, 1)
        = (-0.3111928097, 1.9867538268)

보다시피 세 점이 전부 object bounds 밖으로 크게 나가 있습니다. 그런데도 CSS 결과는 정상입니다. gradient line이 오브젝트를 가로질러 지나가고, 실제 색상 stop은 18.22%와 141.83%에 놓이기 때문입니다.

데모의 Use Figma sample 버튼은 바로 이 값을 사용합니다. 버튼을 누르면 위 gradientTransform에서 G^-1로 handle 세 점을 복원해서 화면에 배치합니다. handle이 박스 밖으로 나가는 것도 그대로 보여야 합니다. 이걸 클리핑해버리면 실제 Figma식 gradient editor와 달라집니다.

color stop을 드래그하면 무엇이 바뀌나

gradient handle은 gradient 좌표계를 바꾸고, color stop은 그 좌표계 안에서의 position을 바꿉니다.

linear와 radial에서 stop marker를 움직일 때는 보통 handle 0에서 handle 1로 가는 축에 포인터를 투영합니다.

axis = handle1 - handle0
position = dot(pointer - handle0, axis) / dot(axis, axis)
position = clamp(position, 0, 1)

이 값이 곧 gradientStops[i].position입니다. 그래서 stop marker를 중간으로 끌면 Figma 데이터에서는 색상 자체가 바뀌는 게 아니라 position만 바뀝니다.

angular gradient에서는 축 투영이 아니라 각도를 씁니다.

startAngle = angle(handle1 - handle0)
pointerAngle = angle(pointer - handle0)
position = normalizeAngle(pointerAngle - startAngle) / 360deg

데모의 color stop marker도 이 규칙으로 움직입니다. linear/radial 모드에서는 선 위로 투영되고, angular 모드에서는 중심을 기준으로 빙글 돌면서 0..1 position을 만듭니다. 드디어 stop 친구들도 일을 하기 시작했습니다.

CSS angle 공식

CSS gradient 각도는 화면 위쪽이 0deg, 오른쪽이 90deg입니다. 그래서 화면 좌표계의 벡터에서 CSS angle을 만들 때는 이렇게 둡니다.

angle = 90deg + atan2(c, a)

우리가 흔히 쓰는 화면 벡터 공식과 다르게 보일 수 있습니다. 여기서는 gradientTransform의 첫 번째 row (a, c)가 Figma의 linear gradient scalar field 방향을 나타내기 때문입니다.

background: linear-gradient(
  56.7deg,
  #f6c85f 12.4%,
  #0d9488 38.2%,
  #9ac8eb 61.3%,
  #101820 82.0%
);

위 숫자들은 예시입니다. 중요한 건 stop이 Figma의 0%, 38%, 72%, 100% 그대로가 아니라, CSS gradient line 기준으로 다시 계산된 값이라는 점입니다.

radial-gradient 변환

Figma radial gradient는 보통 H0를 중심, H1과 H2를 반지름 방향으로 보면 이해하기 좋습니다.

center = P0
rx = length(P1 - P0)
ry = length(P2 - P0)

CSS는 중심과 반지름을 직접 적을 수 있습니다.

background: radial-gradient(
  ellipse 180px 120px at 48% 52%,
  #f6c85f 0%,
  #0d9488 38%,
  #9ac8eb 72%,
  #101820 100%
);

하지만 조심할 점이 있습니다. CSS radial-gradient() 자체에는 타원을 임의 각도로 회전시키는 문법이 없습니다. Figma의 두 radius handle이 회전된 ellipse를 만들고 있다면 선택지가 필요합니다.

1. rotated wrapper 또는 pseudo-element로 gradient layer 자체를 회전한다.
2. SVG gradient로 옮긴다.
3. 정확도가 더 중요하면 raster asset으로 export한다.

“CSS로 다 된다”가 아니라 “CSS가 직접 표현하는 모델과 Figma 모델의 교집합이 어디까지냐”를 보는 게 중요합니다. 이게 변환기의 품격입니다. 갑자기 품격이라니 좀 웃기지만, 맞습니다.

angular gradient는 conic-gradient로

Figma의 angular gradient는 CSS의 conic-gradient()와 가장 가까운 관계입니다.

center = P0
from = atan2(P1.x - P0.x, -(P1.y - P0.y))
stopAngle = stop.position * 360deg
background: conic-gradient(
  from 36deg at 50% 50%,
  #f6c85f 0deg,
  #0d9488 136.8deg,
  #9ac8eb 259.2deg,
  #101820 360deg
);

여기서도 중심점은 CSS의 at x y로 옮기고, stop position은 360deg 기준 각도로 바꿉니다.

diamond gradient는 CSS만으로 어렵다

Figma에는 diamond gradient도 있습니다. CSS에는 diamond-gradient()가 없습니다. 이런 경우는 근사와 정확도를 분리해서 판단합니다.

근사: radial-gradient 또는 conic-gradient 조합
정확: SVG filter/mask, canvas, raster export

디자인 에디터를 만든다면 export UI에서 이런 차이를 숨기지 않는 편이 좋습니다. “CSS로 변환됨”과 “CSS로 근사됨”은 다릅니다. 사용자가 모르면 나중에 QA가 압니다. QA는 늘 압니다.

데모에서 볼 것

데모에서 handle 0, 1, 2를 드래그해보세요. color stop marker도 드래그할 수 있습니다.

linear 모드에서는 Figma handle 선분과 CSS gradient line이 다르기 때문에 stop 위치를 다시 계산합니다. radial 모드에서는 중심과 두 radius를 CSS ellipse로 바꿉니다. angular 모드에서는 중심과 시작 각도를 conic-gradient()로 바꿉니다.

오늘의 핵심은 이것입니다. Figma gradient를 CSS gradient로 바꾸는 일은 문자열 치환이 아니라, normalized object space의 handle을 CSS gradient 좌표계로 다시 투영하는 일입니다.

Edit this page
최근 수정: 26. 5. 14. PM 5:45
Contributors: easylogic
Prev
gradient 밖의 CSS 수학
Next
수학이 필요한 Figma to CSS 속성들