Created
March 31, 2025 04:22
-
-
Save easylogic/c6d2ce1fd59d0e7ba26b4a4b84cb8bcb to your computer and use it in GitHub Desktop.
Transform figma linear gradient to css linear gradient
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { ColorTransformer } from './ColorTransformer'; | |
import { MatrixTransformer } from './MatrixTransformer'; | |
interface Point { | |
x: number; | |
y: number; | |
} | |
interface RGBA { | |
r: number; | |
g: number; | |
b: number; | |
a: number; | |
} | |
interface ColorStop { | |
position: number; // Should be 0-1 initially | |
color: RGBA; | |
} | |
interface GradientFill { | |
type: string; | |
gradientStops: ColorStop[]; | |
gradientTransform: number[][]; | |
} | |
/** | |
* GradientTransformer - Utility class for converting Figma gradients to CSS gradients | |
* Based on logic from: https://velog.io/@easylogic/Figma%EC%97%90%EC%84%9C-CSS%EB%A1%9C-Linear-Gradient-%EB%B3%80%ED%99%98%EC%9D%98-%EB%AA%A8%EB%93%A0-%EA%B2%83 | |
*/ | |
export class GradientTransformer { | |
/** | |
* Standard handle positions for Figma gradients | |
* These positions represent the handles in an identity matrix | |
* | |
* First handle: (0, 0.5) - Linear gradient start point | |
* Second handle: (1, 0.5) - Linear gradient end point | |
* Third handle: (0, 1) - Rotation/transformation control point | |
*/ | |
private static readonly identityHandles: Point[] = [ | |
{ x: 0, y: 0.5 }, | |
{ x: 1, y: 0.5 }, | |
{ x: 0, y: 1 } | |
]; | |
/** | |
* Converts RGBA color to HEX string (can include alpha channel) | |
*/ | |
private static rgbaToHex(color: RGBA): string { | |
// Handle potential missing alpha channel gracefully | |
const a = color.a !== undefined ? color.a : 1; | |
const r = Math.round(color.r * 255); | |
const g = Math.round(color.g * 255); | |
const b = Math.round(color.b * 255); | |
const alphaHex = Math.round(a * 255); | |
const toHex = (n: number): string => ('0' + n.toString(16)).slice(-2); | |
if (a === 1) { // If alpha is 1, return 6-digit hex | |
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase(); | |
} | |
// Otherwise, return 8-digit hex | |
return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alphaHex)}`.toUpperCase(); | |
} | |
/** | |
* Formats a number as a CSS percentage string (up to two decimal places) | |
*/ | |
private static formatPercentage(value: number): string { | |
// Ensure the output always has two decimal places as per test expectations | |
return Number(value.toFixed(2)).toString(); | |
} | |
/** | |
* Calculates the inverse of a 2x3 transformation matrix | |
* Input matrix format: [[a, c, e], [b, d, f]] | |
*/ | |
private static inverseMatrix(matrix: number[][]): number[][] { | |
const epsilon = 1e-6; | |
if (!matrix || matrix.length !== 2 || matrix[0].length !== 3 || matrix[1].length !== 3) { | |
console.error("Invalid matrix format for inversion:", matrix); | |
return [[1, 0, 0], [0, 1, 0]]; // Return identity | |
} | |
const [a, c, e_trans] = matrix[0]; | |
const [b, d, f_trans] = matrix[1]; | |
const det = a * d - b * c; | |
if (Math.abs(det) < epsilon) { | |
console.warn("Matrix determinant is close to zero, inversion may be unstable."); | |
return [[1, 0, 0], [0, 1, 0]]; // Return identity for singular matrix | |
} | |
const invDet = 1 / det; | |
// Calculate inverse matrix elements | |
const invA = d * invDet; | |
const invC = -c * invDet; | |
const invE = (c * f_trans - d * e_trans) * invDet; | |
const invB = -b * invDet; | |
const invD = a * invDet; | |
const invF = (b * e_trans - a * f_trans) * invDet; | |
return [ | |
[invA, invC, invE], | |
[invB, invD, invF], | |
]; | |
} | |
/** | |
* Applies a 2x3 transformation matrix to a point | |
* Matrix format: [[a, c, e], [b, d, f]] | |
* Point format: [x, y] | |
*/ | |
private static applyMatrixToPoint(matrix: number[][], point: number[]): Point { | |
const epsilon = 1e-6; | |
if (!matrix || matrix.length !== 2 || matrix[0].length !== 3 || matrix[1].length !== 3) { | |
console.error("Invalid matrix format for point application:", matrix); | |
return { x: point[0] ?? 0, y: point[1] ?? 0 }; | |
} | |
if (!point || point.length !== 2) { | |
console.error("Invalid point format for matrix application:", point); | |
return { x: 0, y: 0}; | |
} | |
return { | |
x: matrix[0][0] * point[0] + matrix[0][1] * point[1] + matrix[0][2], | |
y: matrix[1][0] * point[0] + matrix[1][1] * point[1] + matrix[1][2], | |
}; | |
} | |
/** | |
* Calculates the actual start and end points using Figma's gradient transformation matrix | |
* Applies the inverse matrix to standard handles [0, 0.5], [1, 0.5] to find positions in element coordinate space | |
*/ | |
private static calculateFigmaPositionsInElement(gradientTransform: number[][], width: number, height: number): { start: Point; end: Point } { | |
const matrixInverse = this.inverseMatrix(gradientTransform); | |
// Standard handles (in Figma gradient space) | |
const handleStartGradientSpace = [this.identityHandles[0].x, this.identityHandles[0].y]; | |
const handleEndGradientSpace = [this.identityHandles[1].x, this.identityHandles[1].y]; | |
// Transform standard handles to element space (0-1) using inverse matrix | |
const normalizedStart = this.applyMatrixToPoint(matrixInverse, handleStartGradientSpace); | |
const normalizedEnd = this.applyMatrixToPoint(matrixInverse, handleEndGradientSpace); | |
// Convert element space coordinates to pixels | |
return { | |
start: { x: normalizedStart.x * width, y: normalizedStart.y * height }, | |
end: { x: normalizedEnd.x * width, y: normalizedEnd.y * height }, | |
}; | |
} | |
/** | |
* Calculates CSS gradient angle (0deg=top, clockwise) | |
* @param start Gradient start point (pixels) | |
* @param end Gradient end point (pixels) | |
*/ | |
private static calculateCSSAngle(start: Point, end: Point): number { | |
const dx = end.x - start.x; | |
const dy = end.y - start.y; | |
const epsilon = 1e-6; | |
// Return default angle 0 if vector length is close to zero | |
if (Math.abs(dx) < epsilon && Math.abs(dy) < epsilon) { | |
return 0; | |
} | |
// Calculate mathematical angle (radians, 0deg=right) | |
let angle = Math.atan2(dy, dx); | |
// Following angle conversion logic: | |
// 1. Calculate Figma angle (0deg=right, clockwise) from atan2 result | |
let figmaAngleDeg = angle * (180 / Math.PI); | |
// 2. Convert to CSS angle (0deg=top, clockwise) | |
// CSS angle = Figma angle + 90deg | |
// Figma Angle (-180 ~ 180) -> CSS Angle (needs normalization) | |
// Example: Figma 0 (Right) -> CSS 90 | |
// Example: Figma 90 (Up) -> CSS 0 | |
// Example: Figma 180 (Left) -> CSS 270 | |
// Example: Figma -90 (Down) -> CSS 180 | |
let cssAngleDeg = (figmaAngleDeg + 90) % 360; | |
// Normalize angle to 0-360 range | |
cssAngleDeg = cssAngleDeg < 0 ? 360 + cssAngleDeg : cssAngleDeg; | |
return cssAngleDeg === 360 ? 0 : cssAngleDeg; | |
} | |
/** | |
* Calculates CSS gradient length based on element size and angle (based on CSS spec) | |
* @param width Element width (pixels) | |
* @param height Element height (pixels) | |
* @param cssAngleDeg Gradient angle (CSS standard, 0=top) | |
*/ | |
private static calculateCSSGradientLength(width: number, height: number, cssAngleDeg: number): number { | |
// Convert CSS angle to mathematical angle (radians, 0=right) | |
const mathAngleRad = cssAngleDeg * Math.PI / 180; | |
// Apply CSS spec formula (|W * sin(θ)| + |H * cos(θ)|) | |
// Where θ is the mathematical angle | |
return Math.abs(width * Math.sin(mathAngleRad)) + Math.abs(height * Math.cos(mathAngleRad)); | |
} | |
/** | |
* Calculates CSS gradient start and end points (based on element center, CSS spec) | |
* @param center Element center point {x, y} (pixels) | |
* @param cssAngleDeg CSS gradient angle (0=top) | |
* @param cssGradientLength Calculated CSS gradient line length (pixels) | |
*/ | |
private static calculateCSSLineEndpoints(center: Point, cssAngleDeg: number, cssGradientLength: number): { cssStart: Point; cssEnd: Point } { | |
// Convert CSS angle to mathematical angle (radians, 0=right) | |
const mathAngleRad = (cssAngleDeg - 90) * (Math.PI / 180); | |
const gradientLengthHalf = cssGradientLength / 2; | |
// Calculate start/end points by moving +/- length/2 in angle direction from center | |
return { | |
cssStart: { | |
x: center.x - gradientLengthHalf * Math.cos(mathAngleRad), | |
y: center.y - gradientLengthHalf * Math.sin(mathAngleRad), | |
}, | |
cssEnd: { | |
x: center.x + gradientLengthHalf * Math.cos(mathAngleRad), | |
y: center.y + gradientLengthHalf * Math.sin(mathAngleRad), | |
}, | |
}; | |
} | |
/** | |
* Projects a point onto an infinite line | |
* @param point Point to project | |
* @param lineStart Line start point | |
* @param lineEnd Line end point | |
* @returns Coordinates of the projected point on the line | |
*/ | |
private static projectPointOntoLine(point: Point, lineStart: Point, lineEnd: Point): Point { | |
const lineVecX = lineEnd.x - lineStart.x; | |
const lineVecY = lineEnd.y - lineStart.y; | |
const pointVecX = point.x - lineStart.x; | |
const pointVecY = point.y - lineStart.y; | |
const lineLengthSq = lineVecX ** 2 + lineVecY ** 2; | |
const epsilon = 1e-6; | |
// Return start point if line length is close to zero (projection impossible) | |
if (lineLengthSq < epsilon) { | |
return { ...lineStart }; | |
} | |
// Calculate projection ratio of point vector onto line vector | |
const dotProduct = pointVecX * lineVecX + pointVecY * lineVecY; | |
const projectionRatio = dotProduct / lineLengthSq; | |
// Calculate projected point coordinates | |
return { | |
x: lineStart.x + lineVecX * projectionRatio, | |
y: lineStart.y + lineVecY * projectionRatio, | |
}; | |
} | |
/** | |
* Maps Figma gradient color stops to relative positions on CSS gradient line | |
* @param stops Original Figma color stops array [{ position: 0-1, color: RGBA }] | |
* @param figmaStart Figma gradient start point (pixels) | |
* @param figmaEnd Figma gradient end point (pixels) | |
* @param cssStart CSS gradient line start point (pixels) | |
* @param cssEnd CSS gradient line end point (pixels) | |
* @returns Transformed color stops array [{ position: relative position (can exceed 0-1 range), color: RGBA }] | |
*/ | |
private static mapFigmaStopsToCSSLine( | |
stops: ColorStop[], | |
figmaStart: Point, | |
figmaEnd: Point, | |
cssStart: Point, | |
cssEnd: Point | |
): ColorStop[] { | |
const cssVecX = cssEnd.x - cssStart.x; | |
const cssVecY = cssEnd.y - cssStart.y; | |
const cssLengthSq = cssVecX ** 2 + cssVecY ** 2; | |
const epsilon = 1e-6; | |
// Use original positions (0-1) if CSS line length is close to zero | |
if (cssLengthSq < epsilon) { | |
console.warn("CSS gradient line length is near zero."); | |
return stops.map(stop => ({ ...stop, position: stop.position })); | |
} | |
const cssLength = Math.sqrt(cssLengthSq); | |
return stops.map((stop, index) => { | |
// Calculate actual pixel position of color stop in Figma space (interpolate from figmaStart) | |
const P_figma = { | |
x: figmaStart.x + (figmaEnd.x - figmaStart.x) * stop.position, | |
y: figmaStart.y + (figmaEnd.y - figmaStart.y) * stop.position, | |
}; | |
// Project P_figma onto CSS gradient line (cssStart -> cssEnd) | |
const projectedPoint = this.projectPointOntoLine(P_figma, cssStart, cssEnd); | |
// Calculate vector from cssStart to projectedPoint | |
const projectedVecX = projectedPoint.x - cssStart.x; | |
const projectedVecY = projectedPoint.y - cssStart.y; | |
// Check if projected vector and CSS vector are in same direction (dot product) | |
// dot product / (magnitude1 * magnitude2) = cos(angle) -> we only need the sign | |
const dotProductProjected = projectedVecX * cssVecX + projectedVecY * cssVecY; | |
const dotProductSign = Math.sign(dotProductProjected); | |
// Calculate distance from cssStart to projectedPoint | |
const projectedLength = Math.sqrt(projectedVecX ** 2 + projectedVecY ** 2); | |
// Calculate signed distance using dot product sign | |
// dotProductSign will be 0 if projectedPoint equals cssStart | |
const signedProjectedDistance = dotProductSign * projectedLength; | |
// Calculate relative position along CSS gradient line (can exceed 0-1 range) | |
const relativePosition = signedProjectedDistance / cssLength; | |
const result = { | |
...stop, // Keep original color | |
position: relativePosition, // Use calculated CSS relative position | |
}; | |
return result; | |
}); | |
} | |
/** | |
* Converts Figma linear gradient to CSS gradient string | |
* @param gradientFill Figma gradient data object | |
* @param width Element width (pixels, default 100) | |
* @param height Element height (pixels, default 100) | |
*/ | |
static linearGradientToCss(gradientFill: GradientFill, width: number = 100, height: number = 100): string { | |
// Input validation | |
if (!gradientFill || gradientFill.type !== 'GRADIENT_LINEAR' || !gradientFill.gradientStops || gradientFill.gradientStops.length < 1) { | |
return 'none'; | |
} | |
// Handle single color stop as solid color | |
if (gradientFill.gradientStops.length === 1) { | |
return this.rgbaToHex(gradientFill.gradientStops[0].color); | |
} | |
// --- Matrix Handling --- | |
let transformMatrix: number[][]; | |
const rawMatrix = gradientFill.gradientTransform; | |
const epsilon = 1e-6; | |
// Check and convert matrix format (using Figma standard [[a, c, e], [b, d, f]]) | |
if (Array.isArray(rawMatrix) && rawMatrix.length === 2 && rawMatrix[0]?.length === 3 && rawMatrix[1]?.length === 3) { | |
transformMatrix = rawMatrix; | |
} else if (Array.isArray(rawMatrix) && rawMatrix.length === 6) { | |
// Convert [a, b, c, d, e, f] format to [[a, c, e], [b, d, f]] | |
const [a, b, c, d, e, f] = rawMatrix as unknown as number[]; | |
transformMatrix = [[a, c, e], [b, d, f]]; | |
} else { | |
// Use identity matrix as fallback | |
console.warn("Invalid or missing gradientTransform format, using identity:", rawMatrix); | |
transformMatrix = [[1, 0, 0], [0, 1, 0]]; | |
} | |
// Check matrix determinant | |
const [a_m, c_m, ] = transformMatrix[0]; | |
const [b_m, d_m, ] = transformMatrix[1]; | |
const det = a_m * d_m - b_m * c_m; | |
if (Math.abs(det) < epsilon) { | |
// Matrix with determinant close to zero cannot be properly transformed, return default gradient | |
console.warn("Gradient transform matrix determinant is close to zero. Returning default gradient."); | |
const stopsString = gradientFill.gradientStops.map(stop => | |
`${this.rgbaToHex(stop.color)} ${this.formatPercentage(stop.position * 100)}%` | |
).join(', '); | |
return `linear-gradient(0deg, ${stopsString})`; | |
} | |
// --- Main Conversion Logic --- | |
// 1. Calculate Figma handle positions in element coordinates (pixels) | |
const { start: figmaStart, end: figmaEnd } = this.calculateFigmaPositionsInElement(transformMatrix, width, height); | |
// 2. Calculate element center point | |
const center = { x: width / 2, y: height / 2 }; | |
// 3. Calculate CSS gradient angle (based on figmaStart, figmaEnd) | |
const cssAngle = this.calculateCSSAngle(figmaStart, figmaEnd); | |
// 4. Calculate CSS gradient length (using standard CSS formula, based on angle/size) | |
const cssGradientLength = this.calculateCSSGradientLength(width, height, cssAngle); | |
// 5. Calculate CSS gradient line start/end points (based on center, angle, length) | |
const { cssStart, cssEnd } = this.calculateCSSLineEndpoints(center, cssAngle, cssGradientLength); | |
// 6. Map Figma color stops to relative positions on CSS line | |
const mappedStops = this.mapFigmaStopsToCSSLine( | |
gradientFill.gradientStops as ColorStop[], | |
figmaStart, | |
figmaEnd, | |
cssStart, | |
cssEnd | |
); | |
// 7. Generate final CSS string | |
const stopsString = mappedStops | |
.map((stop) => `${this.rgbaToHex(stop.color)} ${this.formatPercentage(stop.position * 100)}%`) | |
.join(', '); | |
const result = `linear-gradient(${Math.round(cssAngle)}deg, ${stopsString})`; | |
return result; | |
} | |
// --- Additional gradient types (radial, etc.) can be added here --- | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment