Master Figma's variant system — read multi-state component grids, translate hover, focus, active, and disabled states to CSS pseudo-classes, and implement interactive component behavior using CSS-only state machines.
Figma Variants allow designers to define all states of a component in a single organized grid. A button component might have 3 variants (Primary, Secondary, Ghost) × 5 states (Default, Hover, Focus, Active, Disabled) = 15 frames arranged in a grid. Each cell shows exactly what the component should look like in that combination.
For developers, variants are the most precise specification of interactive behavior available. Instead of guessing what a button looks like when hovered, you find the Hover state variant and read its exact property values. This lesson covers how to navigate variant grids, extract state specifications, and translate them into CSS pseudo-classes and ARIA selectors.
A Figma variant component appears as a grid in the component master page. Each row represents one property axis; each column represents another. The properties panel (right sidebar) shows all variant properties and their values.
Navigating a variant grid:
Variant property types developers care about:
Figma state → CSS mapping:
1Default → .btn (base styles, no modifier)
2Hover → .btn:hover
3Focus → .btn:focus-visible
4Pressed → .btn:active
5Disabled → .btn[disabled], .btn[aria-disabled='true']
6Loading → .btn[aria-busy='true']
7Error → .field[aria-invalid='true']
8Selected → .toggle[aria-pressed='true'], [aria-selected='true']
9Expanded → [aria-expanded='true']Reading a complete button variant grid and translating every state to CSS:
Figma variant grid for Primary button (reading each cell):
1/* Complete button state implementation from Figma variant grid */
2.btn-primary {
3 /* Default state — from Default variant cell */
4 background: #2563eb;
5 color: white;
6 border: 1.5px solid #2563eb;
7 box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
8 /* Base structure */
9 display: inline-flex;
10 align-items: center;
11 justify-content: center;
12 gap: 0.375rem;
13 padding: 0.625rem 1.25rem;
14 border-radius: 0.5rem;
15 font-size: 0.9375rem;
16 font-weight: 600;
17 font-family: inherit;
18 cursor: pointer;
19 transition:
20 background-color 150ms ease,
21 border-color 150ms ease,
22 box-shadow 150ms ease,
23 transform 100ms ease;
24 position: relative;
25 overflow: hidden;
26 white-space: nowrap;
27 text-decoration: none;
28}
29
30/* Hover state — from Hover variant cell */
31.btn-primary:hover:not([disabled]):not([aria-disabled='true']) {
32 background: #1d4ed8;
33 border-color: #1d4ed8;
34 box-shadow: 0 4px 6px rgb(0 0 0 / 0.07), 0 2px 4px rgb(0 0 0 / 0.04);
35}
36
37/* Focus state — from Focus variant cell */
38.btn-primary:focus-visible {
39 outline: none;
40 box-shadow:
41 0 0 0 3px white,
42 0 0 0 5px #2563eb;
43 /* ring: 3px #2563EB at 15% opacity — implemented as double ring */
44}
45
46/* Pressed/active state — from Pressed variant cell */
47.btn-primary:active:not([disabled]):not([aria-disabled='true']) {
48 background: #1e40af;
49 border-color: #1e40af;
50 box-shadow: none;
51 transform: scale(0.98);
52}
53
54/* Disabled state — from Disabled variant cell: opacity 40% */
55.btn-primary[disabled],
56.btn-primary[aria-disabled='true'] {
57 opacity: 0.4;
58 cursor: not-allowed;
59 pointer-events: none;
60}
61
62/* Loading state — from Loading variant cell */
63.btn-primary[aria-busy='true'] {
64 cursor: wait;
65 pointer-events: none;
66 color: transparent; /* hide label text, keep space */
67}
68.btn-primary[aria-busy='true']::after {
69 content: '';
70 position: absolute;
71 inset: 0;
72 margin: auto;
73 width: 1rem;
74 height: 1rem;
75 border: 2px solid rgba(255,255,255,0.3);
76 border-top-color: white;
77 border-radius: 50%;
78 animation: btn-spin 600ms linear infinite;
79}
80@keyframes btn-spin { to { transform: rotate(360deg); } }Form fields typically have five states in Figma: Default, Focus, Filled, Error, Disabled. Each is a separate cell in the component's variant grid.
Figma variant grid readings for an Input component:
1/* Form field — all states from Figma variant grid */
2.field {
3 /* Default state */
4 display: block;
5 width: 100%;
6 padding: 0.625rem 0.875rem;
7 border: 1.5px solid #d1d5db;
8 border-radius: 0.5rem;
9 background: white;
10 font-size: 1rem;
11 font-family: inherit;
12 color: #111827;
13 line-height: 1.5;
14 transition:
15 border-color 150ms ease,
16 box-shadow 150ms ease,
17 background-color 150ms ease;
18 outline: none;
19 appearance: none;
20}
21
22/* Placeholder: #9CA3AF */
23.field::placeholder {
24 color: #9ca3af;
25}
26
27/* Focus state */
28.field:focus {
29 border-color: #2563eb;
30 box-shadow: 0 0 0 3px rgb(37 99 235 / 0.12);
31}
32
33/* Filled state — when field has value (JavaScript adds data-filled) */
34.field[data-filled='true'] {
35 border-color: #d1d5db; /* same as default — Figma spec */
36 color: #111827;
37}
38
39/* Error state — driven by ARIA attribute */
40.field[aria-invalid='true'] {
41 border-color: #dc2626;
42 background: #fef2f2;
43 box-shadow: 0 0 0 3px rgb(220 38 38 / 0.07);
44}
45.field[aria-invalid='true']:focus {
46 border-color: #dc2626;
47 box-shadow: 0 0 0 3px rgb(220 38 38 / 0.15);
48}
49
50/* Disabled state */
51.field:disabled,
52.field[aria-disabled='true'] {
53 border: 1px solid #e5e7eb;
54 background: #f9fafb;
55 color: #9ca3af;
56 cursor: not-allowed;
57 opacity: 1; /* override browser default opacity, use bg color instead */
58}Figma Interactive Components allow designers to connect variant states with prototype interactions. A hover interaction connects Default → Hover variant with an 'instant' or 'ease-in' transition. These connections specify the CSS transition timing.
Reading prototype interaction properties:
Figma animation types → CSS transitions:
1/* Figma 'Instant' → immediate state change */
2.btn { transition: none; }
3
4/* Figma 'Ease In 200ms' → CSS ease-in */
5.btn { transition: background-color 200ms ease-in; }
6
7/* Figma 'Ease Out 150ms' → CSS ease-out */
8.btn { transition: background-color 150ms ease-out; }
9
10/* Figma 'Ease In Out 250ms' → CSS ease-in-out */
11.btn { transition: background-color 250ms ease-in-out; }
12
13/* Figma 'Linear' → CSS linear */
14.btn { transition: background-color 200ms linear; }
15
16/* Figma 'Gentle spring' → CSS cubic-bezier approximation */
17.btn { transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1); }
18
19/* Figma 'Quick spring' → tighter cubic-bezier */
20.btn { transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1); }For the most common transitions in UI:
Performance note: only transition transform and opacity for animations involving movement. Transitioning background-color, border-color, and box-shadow is acceptable for hover states but avoid combining more than 3 transitioned properties on any element.
A complete toggle button component implementing all Figma variant states — off/on × default/hover/focus/disabled — using CSS pseudo-classes and ARIA attributes.
1<!--
2FIGMA VARIANT GRID: Toggle / Bookmark component
3Property 1: state (off, on)
4Property 2: interaction (default, hover, focus, disabled)
5
6Off-Default: bg transparent, border 1.5px #D1D5DB, icon #9CA3AF
7Off-Hover: bg #F9FAFB, border 1.5px #D1D5DB, icon #374151
8Off-Focus: bg transparent, border 1.5px #2563EB, ring 3px, icon #9CA3AF
9Off-Disabled: bg #F9FAFB, border 1px #E5E7EB, icon #D1D5DB, opacity 50%
10
11On-Default: bg #EFF6FF, border 1.5px #2563EB, icon #2563EB
12On-Hover: bg #DBEAFE, border 1.5px #1D4ED8, icon #1D4ED8
13On-Focus: bg #EFF6FF, border 1.5px #2563EB, ring 3px, icon #2563EB
14On-Disabled: bg #EFF6FF, border 1px #93C5FD, icon #93C5FD, opacity 50%
15-->
16<!DOCTYPE html>
17<html lang="en">
18<head>
19 <meta charset="UTF-8">
20 <title>Toggle Button Variants Demo</title>
21 <style>
22 *, *::before, *::after { box-sizing: border-box; }
23 body { font-family: system-ui, sans-serif; padding: 2rem;
24 background: #f8fafc; color: #111827; }
25 .demo { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center;
26 margin-bottom: 2rem; }
27 .demo-label { font-size: 0.875rem; color: #6b7280; width: 100%;
28 font-weight: 600; text-transform: uppercase;
29 letter-spacing: 0.05em; }
30
31 /* Toggle button base — Off-Default state */
32 .toggle-btn {
33 display: inline-flex;
34 align-items: center;
35 justify-content: center;
36 width: 2.25rem; /* 36px */
37 height: 2.25rem;
38 border-radius: 0.5rem;
39 border: 1.5px solid #d1d5db;
40 background: transparent;
41 color: #9ca3af; /* icon color */
42 cursor: pointer;
43 transition:
44 background-color 150ms ease,
45 border-color 150ms ease,
46 color 150ms ease,
47 box-shadow 150ms ease;
48 outline: none;
49 }
50
51 /* Off-Hover state */
52 .toggle-btn:hover:not([disabled]):not([aria-disabled='true']) {
53 background: #f9fafb;
54 color: #374151;
55 }
56
57 /* Off-Focus state */
58 .toggle-btn:focus-visible {
59 border-color: #2563eb;
60 box-shadow: 0 0 0 3px rgb(37 99 235 / 0.15);
61 }
62
63 /* Off-Disabled state */
64 .toggle-btn[disabled],
65 .toggle-btn[aria-disabled='true'] {
66 background: #f9fafb;
67 border: 1px solid #e5e7eb;
68 color: #d1d5db;
69 cursor: not-allowed;
70 opacity: 0.5;
71 }
72
73 /* On state — aria-pressed="true" drives the 'on' visual */
74 /* On-Default */
75 .toggle-btn[aria-pressed='true'] {
76 background: #eff6ff;
77 border-color: #2563eb;
78 color: #2563eb;
79 }
80
81 /* On-Hover */
82 .toggle-btn[aria-pressed='true']:hover:not([disabled]):not([aria-disabled='true']) {
83 background: #dbeafe;
84 border-color: #1d4ed8;
85 color: #1d4ed8;
86 }
87
88 /* On-Focus (inherits .toggle-btn:focus-visible ring, overrides color) */
89 .toggle-btn[aria-pressed='true']:focus-visible {
90 background: #eff6ff;
91 border-color: #2563eb;
92 color: #2563eb;
93 box-shadow: 0 0 0 3px rgb(37 99 235 / 0.15);
94 }
95
96 /* On-Disabled */
97 .toggle-btn[aria-pressed='true'][disabled],
98 .toggle-btn[aria-pressed='true'][aria-disabled='true'] {
99 background: #eff6ff;
100 border: 1px solid #93c5fd;
101 color: #93c5fd;
102 opacity: 0.5;
103 }
104
105 /* Bookmark SVG icon */
106 .icon { width: 1.125rem; height: 1.125rem; fill: none;
107 stroke: currentColor; stroke-width: 2;
108 stroke-linecap: round; stroke-linejoin: round; }
109
110 .state-label { font-size: 0.75rem; color: #9ca3af; }
111 </style>
112</head>
113<body>
114 <h1 style="font-size:1.25rem;font-weight:700;margin-bottom:1.5rem;">Toggle Button — All Variant States</h1>
115
116 <div class="demo">
117 <span class="demo-label">Off state</span>
118 <!-- Off-Default -->
119 <div style="display:flex;flex-direction:column;align-items:center;gap:0.375rem;">
120 <button class="toggle-btn" type="button" aria-pressed="false" aria-label="Save course">
121 <svg class="icon" viewBox="0 0 24 24"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg>
122 </button>
123 <span class="state-label">Default</span>
124 </div>
125 <!-- Off-Disabled -->
126 <div style="display:flex;flex-direction:column;align-items:center;gap:0.375rem;">
127 <button class="toggle-btn" type="button" aria-pressed="false" aria-label="Save course" disabled>
128 <svg class="icon" viewBox="0 0 24 24"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg>
129 </button>
130 <span class="state-label">Disabled</span>
131 </div>
132 </div>
133
134 <div class="demo">
135 <span class="demo-label">On state (aria-pressed=true)</span>
136 <!-- On-Default -->
137 <div style="display:flex;flex-direction:column;align-items:center;gap:0.375rem;">
138 <button class="toggle-btn" type="button" aria-pressed="true" aria-label="Unsave course">
139 <svg class="icon" viewBox="0 0 24 24"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z" fill="currentColor"/></svg>
140 </button>
141 <span class="state-label">Default</span>
142 </div>
143 <!-- On-Disabled -->
144 <div style="display:flex;flex-direction:column;align-items:center;gap:0.375rem;">
145 <button class="toggle-btn" type="button" aria-pressed="true" aria-label="Unsave course" disabled>
146 <svg class="icon" viewBox="0 0 24 24"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z" fill="currentColor"/></svg>
147 </button>
148 <span class="state-label">Disabled</span>
149 </div>
150 </div>
151</body>
152</html>Every CSS rule corresponds to a specific Figma variant grid cell. The base .toggle-btn implements the Off-Default variant. :hover implements Off-Hover. :focus-visible implements Off-Focus with the exact 3px ring. [aria-pressed='true'] implements On-Default — using the semantic ARIA attribute as the CSS selector ensures state is always in sync between visual appearance and accessibility tree. The disabled state uses the [disabled] attribute selector rather than an .is-disabled class — this guarantees the button is actually disabled at the HTML level, not just visually styled to look disabled.
The next lesson covers Figma Design Tokens — the variable system, how tokens link colors and spacing to semantic names, and how to export the token graph to CSS custom properties.
Your progress is saved in this browser, on this device only.