Read and translate Figma component variant grids into CSS pseudo-class implementations. Understand component properties, variant sets, and how to map all interactive states — default, hover, focus, active, disabled, error — to production CSS.
Figma components are the design equivalent of React components — reusable, named units that can be placed anywhere in a design file as instances. Variants extend this by grouping multiple states of a component into a single grid, letting designers define hover, focus, disabled, and error states all in one place.
As a developer, you do not build components in Figma — you read them. Your job is to look at a component's variant grid and understand exactly how many CSS rules you need to write to cover every state the designer specified. This lesson covers how to read variant grids, map states to CSS selectors, and extract values for all states efficiently from the Inspect panel.
A Figma component is created by right-clicking any frame and selecting 'Create component'. The original is the main component; every copy placed on canvas is an instance. Instances inherit the main component's properties but can have overrides (different text, icons, colors).
Component properties (visible in the right panel when a component is selected):
How to identify component instances in Figma:
Reading component properties as a developer:
1Component: Button
2Properties:
3 State: Default | Hover | Focus | Active | Disabled ← maps to CSS pseudo-classes
4 Type: Primary | Secondary | Ghost | Danger ← maps to CSS modifier classes
5 Size: SM | MD | LG ← maps to CSS size variants
6 Icon: true | false ← maps to conditional rendering
7 Loading: true | false ← maps to JS state + CSS classThis component has 5 × 4 × 3 × 2 × 2 = 240 possible combinations — but many share the same CSS rules. Your job is to collapse them into the minimum number of CSS rules that covers all states.
Component vs Group in the layers panel:
When you open a Figma design system file, the component page shows a variant grid — rows and columns where each cell is one combination of properties. This grid directly tells you which CSS selectors you need.
Example variant grid for a Button component:
1 Default Hover Focus Active Disabled
2Primary [btn] [btn:h] [btn:f] [btn:a] [btn:d]
3Secondary[btn] [btn:h] [btn:f] [btn:a] [btn:d]
4Ghost [btn] [btn:h] [btn:f] [btn:a] [btn:d]Mapping each column to CSS:
1/* Default — no pseudo-class needed */
2.btn-primary { background-color: #2563eb; color: white; }
3
4/* Hover column */
5.btn-primary:hover { background-color: #1d4ed8; }
6
7/* Focus column — use :focus-visible, not :focus */
8.btn-primary:focus-visible {
9 outline: 2px solid #2563eb;
10 outline-offset: 2px;
11}
12
13/* Active column */
14.btn-primary:active { background-color: #1e40af; transform: scale(0.98); }
15
16/* Disabled column — use attribute, not class */
17.btn-primary:disabled,
18.btn-primary[aria-disabled="true"] {
19 background-color: #93c5fd;
20 color: #ffffff;
21 cursor: not-allowed;
22 pointer-events: none;
23}Why :focus-visible not :focus:
Figma's 'Focus' variant maps to keyboard focus only — the ring that appears when tabbing. Use :focus-visible so mouse clicks do not show the focus ring, matching the Figma design exactly.
Why [aria-disabled] not .disabled:
Disabled buttons need aria-disabled="true" for accessibility. Using a CSS attribute selector keeps styling and semantics aligned. Never use pointer-events: none alone — it does not prevent keyboard activation.
Extracting hex values for each state from the Inspect panel:
Most Figma components use Auto Layout for their internal structure. The Inspect panel shows the Flexbox equivalent for any Auto Layout frame. Here is how to read each property:
Auto Layout panel properties → CSS equivalents:
1Direction: Horizontal → flex-direction: row
2Direction: Vertical → flex-direction: column
3Spacing: 8 → gap: 0.5rem (8 / 16)
4Padding: 10 20 → padding: 0.625rem 1.25rem
5Align items: Center → align-items: center
6Justify: Space between → justify-content: space-between
7Fill container → width: 100% (or flex: 1)
8Hug contents → width: fit-contentComplete button CSS from a Figma Auto Layout component:
1/* Reading the button Auto Layout properties:
2 Direction: Horizontal, Gap: 8px, Padding: 10px 20px
3 Align items: Center, Hug contents, Min width: 80px */
4.btn {
5 /* Auto Layout → Flexbox */
6 display: inline-flex;
7 flex-direction: row;
8 align-items: center;
9 justify-content: center;
10 gap: 0.5rem; /* 8px gap between icon and label */
11
12 /* Padding from Auto Layout */
13 padding: 0.625rem 1.25rem; /* 10px 20px */
14
15 /* Min width from constraints */
16 min-width: 5rem; /* 80px */
17
18 /* Typography from text layer */
19 font-family: 'Inter', system-ui, sans-serif;
20 font-size: 0.9375rem; /* 15px */
21 font-weight: 600;
22 line-height: 1.5;
23 white-space: nowrap;
24
25 /* Shape from Inspect panel */
26 border-radius: 0.5rem; /* 8px */
27 border: none;
28 cursor: pointer;
29 text-decoration: none;
30
31 /* Smooth transitions for hover/active */
32 transition:
33 background-color 150ms ease,
34 color 150ms ease,
35 transform 100ms ease;
36}
37
38/* Icon child layer: 20px square, flex-shrink: 0 */
39.btn > svg,
40.btn > .icon {
41 width: 1.25rem;
42 height: 1.25rem;
43 flex-shrink: 0; /* prevents icon from squishing */
44}
45
46/* Loading state: icon replaced with spinner */
47.btn[data-loading="true"] {
48 position: relative;
49 color: transparent; /* hides label during loading */
50}
51.btn[data-loading="true"]::after {
52 content: '';
53 position: absolute;
54 width: 1rem;
55 height: 1rem;
56 border: 2px solid currentColor;
57 border-top-color: transparent;
58 border-radius: 50%;
59 animation: spin 600ms linear infinite;
60}Boolean properties map to conditional rendering in React:
1function Button({
2 children,
3 icon, // Boolean property: show icon
4 loading, // Boolean property: show spinner
5 variant = 'primary',
6 size = 'md',
7 disabled,
8}: ButtonProps) {
9 return (
10 <button
11 className={`btn btn-${variant} btn-${size}`}
12 disabled={disabled}
13 aria-disabled={disabled}
14 data-loading={loading}
15 >
16 {icon && !loading && <span className="icon">{icon}</span>}
17 {children}
18 </button>
19 );
20}Form inputs are one of the most variant-heavy components in any design system. A typical Figma input component has five state variants. Here is how to extract and implement all five:
Figma Input variant grid:
1Default → normal resting state, no interaction
2Focus → user is typing (blue border, blue label)
3Filled → has a value but not focused
4Error → validation failed (red border, red message)
5Disabled → not interactive (muted, cursor not-allowed)CSS implementation for all five states:
1/* Base styles (Default state) */
2.input-wrapper {
3 display: flex;
4 flex-direction: column;
5 gap: 0.375rem; /* 6px from Figma gap between label and input */
6}
7
8.input-label {
9 font-size: 0.875rem; /* 14px */
10 font-weight: 500;
11 color: #374151; /* Default label: #374151 from Inspect panel */
12 transition: color 150ms ease;
13}
14
15.input-field {
16 display: block;
17 width: 100%;
18 padding: 0.625rem 0.75rem; /* 10px 12px */
19 font-size: 1rem;
20 font-family: 'Inter', system-ui, sans-serif;
21 color: #111827; /* filled text color */
22 background-color: #ffffff;
23 border: 1.5px solid #d1d5db; /* Default: gray-300 stroke */
24 border-radius: 0.5rem; /* 8px */
25 outline: none;
26 transition:
27 border-color 150ms ease,
28 box-shadow 150ms ease;
29}
30
31/* Focus state (matching Figma Focus variant) */
32.input-field:focus {
33 border-color: #2563eb; /* blue-600 from Figma Focus variant */
34 box-shadow: 0 0 0 3px rgb(37 99 235 / 0.2); /* 20% opacity ring */
35}
36.input-wrapper:has(.input-field:focus) .input-label {
37 color: #2563eb; /* label turns blue on focus */
38}
39
40/* Error state (aria-invalid attribute selector) */
41.input-field[aria-invalid="true"] {
42 border-color: #dc2626; /* red-600 from Figma Error variant */
43}
44.input-field[aria-invalid="true"]:focus {
45 box-shadow: 0 0 0 3px rgb(220 38 38 / 0.2); /* red ring on focus */
46}
47.input-wrapper:has([aria-invalid="true"]) .input-label {
48 color: #dc2626; /* label turns red in error state */
49}
50.input-error-message {
51 font-size: 0.8125rem; /* 13px from Figma */
52 color: #dc2626;
53 display: none;
54}
55[aria-invalid="true"] ~ .input-error-message { display: block; }
56
57/* Disabled state */
58.input-field:disabled {
59 background-color: #f9fafb; /* gray-50 from Figma Disabled variant */
60 border-color: #e5e7eb;
61 color: #9ca3af;
62 cursor: not-allowed;
63}
64.input-wrapper:has(:disabled) .input-label {
65 color: #9ca3af; /* muted label in disabled state */
66}HTML structure matching the Figma component:
1<!-- Default state -->
2<div class="input-wrapper">
3 <label class="input-label" for="email">Email address</label>
4 <input class="input-field" id="email" type="email" placeholder="[email protected]">
5</div>
6
7<!-- Error state -->
8<div class="input-wrapper">
9 <label class="input-label" for="email-err">Email address</label>
10 <input class="input-field" id="email-err" type="email"
11 aria-invalid="true" aria-describedby="email-err-msg">
12 <span class="input-error-message" id="email-err-msg">Enter a valid email address</span>
13</div>
14
15<!-- Disabled state -->
16<div class="input-wrapper">
17 <label class="input-label" for="email-dis">Email address</label>
18 <input class="input-field" id="email-dis" type="email" disabled
19 value="[email protected]">
20</div>Reading a course card component that has Default, Hover, and Enrolled variants, and translating all three states to production CSS without JavaScript.
1/* Figma component: Course Card
2 Variants: State (Default | Hover | Enrolled), Badge (true | false)
3 Auto Layout: Vertical, gap 0, padding 0, 280px × 340px, radius 12px
4 Shadow: Default = none, Hover = 0 8px 24px rgba(0,0,0,0.12)
5 Border: Default = 1px #E5E7EB, Hover = 1px #2563EB, Enrolled = 1px #16A34A */
6
7.course-card {
8 /* Auto Layout → Flexbox column */
9 display: flex;
10 flex-direction: column;
11
12 /* Size from Figma constraints */
13 width: 17.5rem; /* 280px */
14 min-height: 21.25rem; /* 340px */
15
16 /* Shape */
17 border-radius: 0.75rem; /* 12px */
18 border: 1px solid #e5e7eb;
19 background-color: #ffffff;
20 overflow: hidden; /* clips thumbnail to border-radius */
21
22 /* Smooth hover transition */
23 transition:
24 border-color 200ms ease,
25 box-shadow 200ms ease,
26 transform 200ms ease;
27
28 /* Clickable card */
29 cursor: pointer;
30 text-decoration: none;
31 color: inherit;
32}
33
34/* Hover variant */
35.course-card:hover {
36 border-color: #2563eb;
37 box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
38 transform: translateY(-2px); /* subtle lift (developer addition) */
39}
40
41/* Enrolled variant — using data attribute */
42.course-card[data-enrolled="true"] {
43 border-color: #16a34a; /* green-600 from Figma Enrolled variant */
44}
45.course-card[data-enrolled="true"]:hover {
46 border-color: #15803d; /* green-700 on hover when enrolled */
47 box-shadow: 0 8px 24px rgb(22 163 74 / 0.15);
48}
49
50/* Thumbnail: 280px × 160px image at top */
51.course-card__thumbnail {
52 width: 100%;
53 height: 10rem; /* 160px from Figma */
54 object-fit: cover;
55 flex-shrink: 0; /* prevents squishing */
56}
57
58/* Content area: Auto Layout vertical, padding 16px, gap 8px */
59.course-card__body {
60 display: flex;
61 flex-direction: column;
62 gap: 0.5rem; /* 8px gap */
63 padding: 1rem; /* 16px padding */
64 flex: 1; /* fills remaining height */
65}
66
67/* Title: Inter 16px 600, #111827, 2 line clamp */
68.course-card__title {
69 font-size: 1rem;
70 font-weight: 600;
71 color: #111827;
72 line-height: 1.5;
73 display: -webkit-box;
74 -webkit-line-clamp: 2;
75 -webkit-box-orient: vertical;
76 overflow: hidden;
77 margin: 0;
78}
79
80/* Badge variant: inline-flex, 4px 8px padding, 4px radius */
81.course-card__badge {
82 display: inline-flex;
83 align-items: center;
84 padding: 0.25rem 0.5rem; /* 4px 8px */
85 border-radius: 0.25rem; /* 4px */
86 font-size: 0.75rem; /* 12px */
87 font-weight: 500;
88 background-color: #dbeafe; /* blue-100 */
89 color: #1d4ed8; /* blue-700 */
90 width: fit-content;
91}
92
93/* Enrolled badge variant */
94[data-enrolled="true"] .course-card__badge {
95 background-color: #dcfce7; /* green-100 from Enrolled variant */
96 color: #15803d; /* green-700 */
97}
98
99/* Progress bar: only shown in Enrolled variant, h:4px */
100.course-card__progress {
101 margin-top: auto; /* pushes to bottom of card body */
102}
103.course-card__progress-track {
104 height: 0.25rem; /* 4px from Figma */
105 background-color: #f3f4f6;
106 border-radius: 9999px;
107 overflow: hidden;
108}
109.course-card__progress-fill {
110 height: 100%;
111 background-color: #16a34a;
112 border-radius: 9999px;
113 transition: width 400ms ease;
114}
115
116/* Usage */
117/* Default: */
118/* <a href="/courses/css" class="course-card"> ... </a> */
119
120/* Enrolled at 68%: */
121/* <a href="/courses/css" class="course-card" data-enrolled="true"> */
122/* ... */
123/* <div class="course-card__progress"> */
124/* <div class="course-card__progress-track"> */
125/* <div class="course-card__progress-fill" style="width: 68%"></div> */
126/* </div> */
127/* </div> */
128/* </a> */Every CSS value here originates from a specific Figma variant. The border-color transitions between #E5E7EB (Default), #2563EB (Hover), and #16A34A (Enrolled) by reading each variant's stroke color in the Inspect panel. The box-shadow values come from the effect listed in the Hover variant's Inspect panel under 'Effects'. The data-enrolled attribute approach mirrors how Figma uses a boolean property — the visual difference is driven by a single data attribute selector rather than JavaScript class toggling.
The next lesson covers Figma Frames versus Groups and Auto Layout in depth — understanding when a design uses Grid versus Flexbox layout and how to translate Figma's responsive resize constraints to CSS clamp() and min/max functions.
Your progress is saved in this browser, on this device only.