Skip to content

Instantly share code, notes, and snippets.

@easylogic
Created March 31, 2025 04:22
Show Gist options
  • Save easylogic/c6d2ce1fd59d0e7ba26b4a4b84cb8bcb to your computer and use it in GitHub Desktop.
Save easylogic/c6d2ce1fd59d0e7ba26b4a4b84cb8bcb to your computer and use it in GitHub Desktop.
Transform figma linear gradient to css linear gradient
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