Реальному інтерактивному компоненту потрібно мінімум шість станів: default, hover, focus-visible, active (pressed), disabled і loading, а полю вводу ще й error. Опишіть їх у Figma як variant-властивість State з привʼязаними змінними, дайте Claude Code згенерувати React-компонент, що читає ваші токени, і зробіть у Storybook окрему story на кожен стан з перевіркою доступності.

Я Dmytro Nikolaienko, 15 років у продуктовому дизайні, засновник UX Hero. Нижче - які стани потрібні і чому дизайнери найчастіше забувають саме focus і loading, як їх описати у Figma, який промпт дати Claude Code, як покрити все stories і чекліст, який можна прикласти до будь-якого компонента.

Цю тему я наживо розбираю на безкоштовному ефірі UX Hero #3 у четвер, 15 жовтня 2026, о 19:00 за Києвом: беру компонент з Figma і доводжу його до React і Storybook з усіма станами. Реєстрація - на сторінці ефіру.

Що таке стан компонента?

Стан компонента - це те, як компонент виглядає і поводиться в конкретній ситуації взаємодії: під курсором, у фокусі, під час натискання, коли недоступний або чекає на відповідь сервера.

Варіант (primary, secondary) - це що за кнопка. Стан - це що з нею зараз відбувається. Їх важливо не змішувати: у Figma це дві різні властивості, у коді - пропс variant і поведінка, яку частково дає браузер (:hover, :focus-visible, :active), а частково ви (disabled, loading).

Які стани потрібні реальному компоненту?

Базовий набір для кнопки - шість станів. Для поля вводу додається error. Ось що кожен означає:

  • Default - стан спокою, з якого починається все інше.
  • Hover - курсор над елементом. На тач-екранах його фактично немає, тому він не може бути єдиним сигналом інтерактивності.
  • Focus-visible - елемент у фокусі з клавіатури. Обвідка, яку видно на будь-якому фоні.
  • Active (pressed) - момент натискання. Дає відчуття, що клік «зайшов».
  • Disabled - дія зараз недоступна.
  • Loading - дія запущена, чекаємо результат. Кнопка не змінює ширину і не приймає повторний клік.
  • Error - для полів: значення невалідне, поруч текст, що саме не так.

Множення швидко показує масштаб: кнопка з 3 розмірами, 3 типами і 6 станами - це 54 варіанти у Figma. Саме тому стани варто описувати системно, а не малювати «по пам'яті» на кожному екрані.

Чому дизайнери забувають focus і loading?

Бо обидва стани невидимі в тому, як ми працюємо у Figma.

  • Focus не існує для мишки. Ми перевіряємо прототип курсором, тож стан, який бачить лише користувач клавіатури, просто не трапляється на очі. А потім у продакшні або обвідку прибирають через outline: none, або лишають дефолтну браузерну, яка не збігається з дизайн-системою.
  • Loading - це час, а статичний фрейм часу не має. У макеті запит до сервера виконується миттєво, тому ніхто не вирішує, що робить кнопка ці 2 секунди: блокується, показує спінер, міняє текст.

На аудитах я регулярно відкриваю файли, де в кнопки намальовано 3 стани з 6-7 потрібних. Коли такий файл читає AI-агент, він не залишає дірку порожньою. Він вигадує стан сам, і на кожному екрані трохи по-різному.

Як описати стани компонента у Figma?

Три інструменти Figma, кожен для своєї задачі:

  1. Variants. Одна властивість State зі значеннями Default, Hover, Focus, Pressed, Disabled, Loading. Окремо від неї Variant (primary, secondary, ghost) і Size. Не робіть компонент «Button Blue Hover Big» - агент не зрозуміє, де тут стан.
  2. Component properties. Текст кнопки - text property, іконка - boolean плюс instance swap. Так ці речі стануть пропсами, а не новими варіантами.
  3. Bound variables. Кожен колір стану привʼязаний до semantic-змінної: color/action/primary, color/action/primary-hover, color/action/primary-pressed, color/focus-ring, color/action/disabled. Жодного сирого hex.

Для focus намалюйте обвідку окремим шаром з відступом 2px від краю, щоб вона не зливалася з фоном кнопки. У loading покладіть спінер поверх тексту, а текст приховайте, але не видаляйте: тоді ширина кнопки не стрибає. Що таке semantic-змінні і навіщо рівні токенів, розібрано в матеріалі про design tokens простими словами, а як оформити властивості компонента для розробника - у статті про Component API у Figma.

Який промпт дати Claude Code, щоб він згенерував компонент зі станами?

Claude Code читає ваш Figma-файл через Figma MCP: бачить назви варіантів і змінних, а не картинку. Як це підключити, я показував у статті про Figma MCP. Далі важливо не просити «зроби кнопку», а дати контракт:

Зроби React-компонент Button з Figma-компонента за посиланням.
Стани: default, hover, focus-visible, pressed, disabled, loading.
Правила:
- кольори тільки через CSS-змінні з src/tokens.css, жодного hex;
- фокус через :focus-visible, обвідка 2px var(--color-focus-ring), offset 2px;
- disabled через нативний атрибут disabled;
- loading: aria-busy, aria-disabled, кнопка лишається у фокусі,
  ширина не змінюється, повторний клік ігнорується;
- мінімальна висота 44px.
Потім створи Button.stories.jsx з окремою story на кожен стан.

Ядро того, що вийде, виглядає приблизно так. Компонент:

export function Button({ loading = false, onClick, children, ...rest }) {
  return (
    <button
      type="button"
      className="btn"
      aria-busy={loading || undefined}
      aria-disabled={loading || undefined}
      onClick={loading ? undefined : onClick}
      {...rest}
    >
      {loading && <span className="btn-spinner" aria-hidden="true" />}
      <span className="btn-label">{children}</span>
    </button>
  );
}

І стилі, які читають токени:

.btn { min-height: 44px; background: var(--color-action-primary); color: var(--color-on-action); }
.btn:not(:disabled, [aria-disabled="true"]):hover { background: var(--color-action-primary-hover); }
.btn:not(:disabled, [aria-disabled="true"]):active { background: var(--color-action-primary-pressed); }
.btn:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 2px; }
.btn:disabled { background: var(--color-action-disabled); color: var(--color-text-disabled); cursor: not-allowed; }
.btn[aria-busy="true"] .btn-label { visibility: hidden; }

Чому loading не через disabled? Якщо кнопка стає disabled у момент, коли вона у фокусі, браузер скидає фокус, і користувач клавіатури чи скрінрідера губиться на сторінці. aria-disabled блокує дію, але залишає фокус на місці. Якщо у вас Tailwind v4, ті самі токени живуть у @theme і стають класами на кшталт bg-action-primary - деталі в статті про Tailwind v4 і токени.

Як покрити кожен стан у Storybook?

Правило просте: один стан - одна story. Тоді стан можна показати, обговорити і протестувати окремо. Hover, focus-visible і pressed браузер вмикає тільки під час взаємодії, тому для статичного показу є аддон storybook-addon-pseudo-states:

import { expect } from 'storybook/test';
import { Button } from './Button';

export default {
  component: Button,
  args: { children: 'Зберегти' },
  parameters: { a11y: { test: 'error' } },
};

export const Default = {};
export const Hover = { parameters: { pseudo: { hover: true } } };
export const FocusVisible = { parameters: { pseudo: { focusVisible: true } } };
export const Pressed = { parameters: { pseudo: { active: true } } };
export const Disabled = { args: { disabled: true } };
export const Loading = { args: { loading: true } };

export const KeyboardFocus = {
  play: async ({ canvas, userEvent }) => {
    await userEvent.tab();
    await expect(canvas.getByRole('button')).toHaveFocus();
  },
};

Приклад написаний під Storybook 9. Аддон @storybook/addon-a11y проганяє перевірки axe-core на кожній story, а test: 'error' робить порушення доступності помилкою тесту, а не попередженням. Play-функція KeyboardFocus перевіряє те, що дизайнери забувають найчастіше: що до кнопки взагалі можна дійти клавіатурою. Якщо Storybook для вас новий, почніть з базового матеріалу Storybook для дизайнерів, а якщо React - з гайду про перший React-компонент.

Автоматичні перевірки не замінюють ручну. Раз на компонент пройдіть його лише клавіатурою (Tab, Enter, Space) і раз увімкніть скрінрідер: VoiceOver на Mac або NVDA на Windows.

Чекліст: стан, вигляд, вимога доступності, як перевірити

СтанЯк виглядаєВимога доступностіЯк перевірити
Defaultбазові токени фону і текстуконтраст тексту 4.5:1, межі компонента 3:1 (WCAG 1.4.3, 1.4.11)addon-a11y, story Default
Hoverфон primary-hover, курсор pointerне єдиний сигнал інтерактивності, бо на тач-екрані hover немаєpseudo-states hover, огляд у Storybook
Focus-visibleобвідка 2px focus-ring, offset 2pxфокус видно (2.4.7), обвідка 3:1 до фону, не перекрита іншими елементами (2.4.11)play-функція з Tab, ручний прохід клавіатурою
Active / pressedфон primary-pressedспрацьовує і з Enter, і з Space, не тільки мишкоюpseudo-states active, натиснути Space
Disabledприглушені токени, курсор not-allowedнативний disabled, поруч пояснення, чому дія недоступнаstory Disabled, клік не викликає onClick
Loadingспінер, текст прихований, ширина та самаaria-busy, фокус не губиться, повторний клік ігноруєтьсяstory Loading, Tab до кнопки під час завантаження
Error (поле)межа і текст помилки токеном dangeraria-invalid, текст через aria-describedby, не тільки колірскрінрідер зачитує текст помилки

Ще одна цифра на додачу: WCAG 2.2 на рівні AA вимагає ціль для кліку щонайменше 24×24 CSS-пікселі (критерій 2.5.8). Висота 44px з промпту вище цю вимогу перекриває з запасом.

Що змінюється, коли стани описані правильно?

Найпомітніше - зникає пінг-понг «а як виглядає кнопка, коли…». Стан описаний у Figma, названий токеном, зібраний у коді і показаний у Storybook однаково. Дизайнер відкриває публічну лінку на Storybook і бачить усі 6 станів поруч, а не вірить на слово.

І це саме те, у чому дизайнер, що кодить, сильніший за генерацію картинок: макет кнопки сьогодні намалює будь-який AI, а компонент, який не ламається з клавіатури і під час завантаження, - лише той, хто розуміє стани. Більше живих розборів - на YouTube-каналі UX Hero.

Часті питання

Які стани має бути в React-компонента?

Для інтерактивного компонента на кшталт кнопки базовий набір - шість станів: default, hover, focus-visible, active (pressed), disabled і loading. Для полів вводу додається error, а часто ще filled і read-only. Якщо стан не описаний у Figma, розробник або AI-агент його вигадає, і в продукті він виглядатиме по-різному на кожному екрані.

Чим focus відрізняється від focus-visible?

Псевдоклас :focus спрацьовує завжди, коли елемент отримав фокус, зокрема після кліку мишкою. :focus-visible браузер вмикає тоді, коли фокус треба показати, насамперед під час навігації клавіатурою. Тому обвідку фокуса стилізують через :focus-visible: користувач клавіатури її бачить, а після кліку мишкою вона не заважає.

Як описати стани компонента у Figma, щоб їх зрозумів Claude Code?

Зробіть окрему variant-властивість State зі значеннями Default, Hover, Focus, Pressed, Disabled, Loading, а не окремі компоненти з назвами на кшталт Button Blue Dark. Кольори кожного стану привʼяжіть до semantic-змінних (наприклад color/action/primary-hover), а текст і іконки винесіть у component properties. Тоді через Figma MCP Claude Code бачить назви станів і токенів, а не сирі hex-значення.

Як перевірити доступність станів у Storybook?

Зробіть окрему story на кожен стан, підключіть @storybook/addon-a11y, який проганяє перевірки axe-core, і додайте play-функцію, що натискає Tab і перевіряє фокус. Hover, focus-visible і pressed можна показати статично через storybook-addon-pseudo-states. Окремо вручну пройдіть компонент клавіатурою і скрінрідером: автоматичні перевірки ловлять лише частину проблем.

Де навчитись доводити компонент зі станами від Figma до коду?

Безкоштовно - на ефірі UX Hero #3 у четвер 15 жовтня 2026 о 19:00 за Києвом, де Dmytro Nikolaienko наживо збирає компонент зі станами з Figma у React і Storybook через Claude Code (реєстрація на uxhero.design/webinar). Системно - на курсі UX Hero «Design Engineer»: 10 лекцій про Git, React, Tailwind, Storybook, Claude Code і деплой.


Стан, який не намальований у Figma, все одно зʼявиться в продукті. Питання лише в тому, хто його вирішить: ви чи агент навмання. Опишіть шість станів, привʼяжіть їх до токенів і покрийте stories, і компонент перестане дивувати вас у продакшні.