Перейти к содержимому
v3.0.0
English
Русский
Документация

IconButton

IconButton — вариант действия только с иконкой, построенный на той же основе, что и Button.
icon-button.tsx · 7 строк
import { Icon, IconButton } from '@vega-ui/react';
import { Heart, Star } from '@vega-ui/icons';

<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
  <IconButton aria-label='Add to favorites'><Icon><Heart /></Icon></IconButton>
  <IconButton variant='secondary' appearance='ghost' aria-label='Star project'><Icon><Star /></Icon></IconButton>
</div>
Поддержка браузеров
Недавно доступно, 2024
  • Chrome 125
  • Edge 125
  • Firefox 128
  • Safari 18
  • Safari на iOS 18
Размер в бандле
История версий
Добавлен в v1.0.0, последнее изменение в v3.0.0

Кратко

IconButton — вариант действия только с иконкой, построенный на той же основе, что и Button. Это компактный слой действий для случаев, когда видимый текст добавил бы шум, а не ясность.

Импорты

import {
  IconButton,
  type IconButtonProps,
  type IconButtonSize,
} from '@vega-ui/react';

Экспортируемые типы

  • IconButtonProps
  • IconButtonSize

Базовое использование

basic-usage.tsx · 27 строк
import { Icon, IconButton, Text } from '@vega-ui/react';
import { EllipsisIcon, PencilIcon, Share2Icon } from '@vega-ui/icons';
import { useState } from 'react';

export default function BasicUsage() {
  const [lastAction, setLastAction] = useState('');

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-2)', width: '100%' }}>
      <header style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-2)' }}>
        <Text size={4} fontWeight={600} style={{ flex: 1, minWidth: 0 }}>Brand guidelines</Text>
        <IconButton type='button' size='sm' variant='secondary' appearance='transparent' aria-label='Rename document' onClick={() => setLastAction('Rename')}>
          <Icon><PencilIcon /></Icon>
        </IconButton>
        <IconButton type='button' size='sm' variant='secondary' appearance='transparent' aria-label='Share document' onClick={() => setLastAction('Share')}>
          <Icon><Share2Icon /></Icon>
        </IconButton>
        <IconButton type='button' size='sm' variant='secondary' appearance='transparent' aria-label='More actions' onClick={() => setLastAction('More')}>
          <Icon><EllipsisIcon /></Icon>
        </IconButton>
      </header>
      <Text size={2} style={{ color: 'var(--label-secondary)' }}>
        {lastAction === '' ? 'Updated 2 hours ago' : `Clicked: ${lastAction}`}
      </Text>
    </div>
  );
}

Варианты

  • Размеры: xs, sm, md, lg, xl
  • Варианты и оформления, унаследованные от основы кнопки
  • Композиция: кнопка по умолчанию или asChild

Пограничные случаи

  • Всегда задавайте aria-label, потому что видимого текста обычно нет.
  • Соблюдайте визуальный баланс размера глифа иконки и токена размера кнопки.
  • Используйте обычный Button, когда видимый текст сделал бы действие понятнее.
  • Если смысл иконки слишком сильно зависит от окружающего контекста, текстовая кнопка обычно надёжнее.

Частые ошибки

  • Пропущен aria-label.
  • Длинный текст помещается внутрь IconButton.
  • Действия только с иконкой используются там, где их смысл неузнаваем.

Примеры

Базовый

Базовый: действия в заголовке с явными именами

Каждому действию-иконке нужен свой aria-label: компонент его не добавляет. Внутри IconButton Icon без size масштабируется вместе с кнопкой через --icon-size.

basic-usage.tsx · 27 строк
import { Icon, IconButton, Text } from '@vega-ui/react';
import { EllipsisIcon, PencilIcon, Share2Icon } from '@vega-ui/icons';
import { useState } from 'react';

export default function BasicUsage() {
  const [lastAction, setLastAction] = useState('');

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-2)', width: '100%' }}>
      <header style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-2)' }}>
        <Text size={4} fontWeight={600} style={{ flex: 1, minWidth: 0 }}>Brand guidelines</Text>
        <IconButton type='button' size='sm' variant='secondary' appearance='transparent' aria-label='Rename document' onClick={() => setLastAction('Rename')}>
          <Icon><PencilIcon /></Icon>
        </IconButton>
        <IconButton type='button' size='sm' variant='secondary' appearance='transparent' aria-label='Share document' onClick={() => setLastAction('Share')}>
          <Icon><Share2Icon /></Icon>
        </IconButton>
        <IconButton type='button' size='sm' variant='secondary' appearance='transparent' aria-label='More actions' onClick={() => setLastAction('More')}>
          <Icon><EllipsisIcon /></Icon>
        </IconButton>
      </header>
      <Text size={2} style={{ color: 'var(--label-secondary)' }}>
        {lastAction === '' ? 'Updated 2 hours ago' : `Clicked: ${lastAction}`}
      </Text>
    </div>
  );
}

Базовый: размеры

size задаёт сторону квадрата от xs до xl; вложенная иконка занимает треть размера кнопки.

sizes.tsx · 16 строк
import { Icon, IconButton, type IconButtonSize } from '@vega-ui/react';
import { SettingsIcon } from '@vega-ui/icons';

const sizes: IconButtonSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];

export default function Sizes() {
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
      {sizes.map((size) => (
        <IconButton key={size} type='button' size={size} aria-label={`Settings, size ${size}`}>
          <Icon><SettingsIcon /></Icon>
        </IconButton>
      ))}
    </div>
  );
}

Контролируемый/с состоянием

Контролируемый/с состоянием: переключатель «Избранное» с aria-pressed

Подпись остаётся «Favorite», а состояние хранится в aria-pressed, поэтому скринридер объявляет «Favorite, кнопка-переключатель, нажата». Если менять подпись при каждом клике, состояние, наоборот, потеряется.

parent-owned-favorite-toggle.tsx · 24 строки
import { Icon, IconButton } from '@vega-ui/react';
import { HeartIcon } from '@vega-ui/icons';
import { useState } from 'react';

export default function ParentOwnedFavoriteToggle() {
  const [favorite, setFavorite] = useState(false);
  const count = favorite ? 129 : 128;

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)', width: '100%' }}>
      <IconButton
        type='button'
        variant='secondary'
        appearance='ghost'
        aria-label='Favorite'
        aria-pressed={favorite}
        onClick={() => setFavorite(!favorite)}
      >
        <Icon><HeartIcon fill={favorite ? 'currentColor' : 'none'} /></Icon>
      </IconButton>
      <span style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{count} people saved this recipe</span>
    </div>
  );
}

Контролируемый/с состоянием: копирование с временной обратной связью

После копирования иконка на две секунды меняется на галочку, а область статуса объявляет «Copied»: одна только смена иконки для скринридера беззвучна.

copy-to-clipboard.tsx · 48 строк
import { Icon, IconButton, VisuallyHidden } from '@vega-ui/react';
import { CheckIcon, CopyIcon } from '@vega-ui/icons';
import { useEffect, useState } from 'react';

const inviteLink = 'https://app.example.com/invite/7Fq2-K9xD';

export default function CopyToClipboard() {
  const [copied, setCopied] = useState(false);

  useEffect(() => {
    if (!copied) return;

    const timer = setTimeout(() => setCopied(false), 2000);
    return () => clearTimeout(timer);
  }, [copied]);

  const copy = async () => {
    await navigator.clipboard.writeText(inviteLink);
    setCopied(true);
  };

  return (
    <div style={{ display: 'flex', gap: 'var(--spacing-4)', width: '100%' }}>
      <code
        style={{
          flex: 1,
          minWidth: 0,
          alignSelf: 'center',
          padding: 'var(--spacing-4) var(--spacing-6)',
          borderRadius: 'var(--radius-3)',
          background: 'var(--fills-tertiary)',
          fontSize: 'var(--font-size-2)',
          whiteSpace: 'nowrap',
          overflow: 'hidden',
          textOverflow: 'ellipsis',
        }}
      >
        {inviteLink}
      </code>
      <IconButton type='button' size='sm' variant='secondary' appearance='ghost' aria-label='Copy invite link' onClick={copy}>
        <Icon>{copied ? <CheckIcon /> : <CopyIcon />}</Icon>
      </IconButton>
      <VisuallyHidden asChild>
        <span role='status'>{copied ? 'Copied' : ''}</span>
      </VisuallyHidden>
    </div>
  );
}

Форма/интеграция

Форма/интеграция: очистка и отправка рядом с полем

Кнопка очистки использует type='button', чтобы не отправлять форму, и возвращает фокус в поле. Кнопка поиска — настоящий type='submit'; запрос читается через FormData.

inline-clear-action.tsx · 55 строк
import type { CSSProperties } from 'react';
import { Icon, IconButton, Label, TextField, TextFieldInput } from '@vega-ui/react';
import { SearchIcon, XIcon } from '@vega-ui/icons';
import { FormEvent, useRef, useState } from 'react';

export default function InlineClearAction() {
  const input = useRef<HTMLInputElement>(null);
  const [query, setQuery] = useState('quarterly report');
  const [submitted, setSubmitted] = useState('');

  const clear = () => {
    setQuery('');
    input.current?.focus();
  };

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setSubmitted(String(new FormData(event.currentTarget).get('q')));
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor='file-search'>Search files</Label>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
        <TextField style={{ flex: 1, minWidth: 0, paddingInlineEnd: 'var(--spacing-5)' }}>
          <TextFieldInput
            ref={input}
            id='file-search'
            name='q'
            value={query}
            onChange={(event) => setQuery(event.target.value)}
          />
          {query !== '' && (
            <IconButton
              type='button'
              variant='secondary'
              appearance='ghost'
              aria-label='Clear search'
              onClick={clear}
              style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
            >
              <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
            </IconButton>
          )}
        </TextField>
        <IconButton type='submit' aria-label='Search'>
          <Icon><SearchIcon /></Icon>
        </IconButton>
      </div>
      <output style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>
        {submitted === '' ? 'Press Enter or the search button' : `Searched for “${submitted}”`}
      </output>
    </form>
  );
}

Раскладка/оверлей

Раскладка/оверлей: кнопка закрытия в диалоге

Стандартная кнопка закрытия оверлея: круглая призрачная кнопка 24×24 внутри DialogClose asChild, позиционированная логическими отступами, чтобы зеркалиться в раскладках справа налево.

compact-close-action.tsx · 41 строка
import type { CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

export default function CompactCloseAction() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>What’s new</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby='whats-new-title'>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id='whats-new-title' as='h2' size={4} fontWeight={600}>What’s new in 2.4</Heading>
          <DialogClose asChild>
            <IconButton
              aria-label='Close'
              variant='secondary'
              appearance='ghost'
              style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
            >
              <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
            </IconButton>
          </DialogClose>
        </div>
        <Text size={3}>
          Shared libraries, faster exports, and a new dark theme. Press Escape or the cross to return to your work.
        </Text>
      </DialogContent>
    </Dialog>
  );
}

Раскладка/оверлей: тултип для зрячих пользователей

aria-label даёт кнопке доступное имя для вспомогательных технологий, а Tooltip показывает то же имя и сочетание клавиш пользователям мыши и клавиатуры. Тултип связан как описание, поэтому дополнительные детали держите в нём.

tooltip-label.tsx · 50 строк
import { Icon, IconButton, Tooltip, TooltipArrow, TooltipContent, TooltipTrigger } from '@vega-ui/react';
import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon } from '@vega-ui/icons';
import { type ReactNode, useState } from 'react';

const alignments: { value: string, label: string, shortcut: string, icon: ReactNode }[] = [
  { value: 'left', label: 'Align left', shortcut: 'Ctrl+Shift+L', icon: <AlignLeftIcon /> },
  { value: 'center', label: 'Align center', shortcut: 'Ctrl+Shift+E', icon: <AlignCenterIcon /> },
  { value: 'right', label: 'Align right', shortcut: 'Ctrl+Shift+R', icon: <AlignRightIcon /> },
];

export default function TooltipLabel() {
  const [alignment, setAlignment] = useState('left');

  return (
    <div
      role='toolbar'
      aria-label='Paragraph alignment'
      style={{
        display: 'flex',
        gap: 'var(--spacing-2)',
        padding: 'var(--spacing-2)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-4)',
        background: 'var(--surface-primary)',
      }}
    >
      {alignments.map((item) => (
        <Tooltip key={item.value}>
          <TooltipTrigger asChild>
            <IconButton
              type='button'
              size='sm'
              variant='secondary'
              appearance={alignment === item.value ? 'ghost' : 'transparent'}
              aria-label={item.label}
              aria-pressed={alignment === item.value}
              onClick={() => setAlignment(item.value)}
            >
              <Icon>{item.icon}</Icon>
            </IconButton>
          </TooltipTrigger>
          <TooltipContent>
            <TooltipArrow />
            {item.label} · {item.shortcut}
          </TooltipContent>
        </Tooltip>
      ))}
    </div>
  );
}

Ошибка

Ошибка: неудачное удаление

Удалить заблокированный файл не получается: ошибка выводится под строкой и связана с её кнопкой через aria-describedby. Доступное имя включает имя файла, поэтому десять одинаковых иконок корзины всё равно различимы.

failed-remove-action.tsx · 80 строк
import { HelperText, Icon, IconButton } from '@vega-ui/react';
import { FileTextIcon, Trash2Icon } from '@vega-ui/icons';
import { useState } from 'react';

const initialFiles = [
  { name: 'contract-signed.pdf', locked: true },
  { name: 'brief-v3.docx', locked: false },
  { name: 'moodboard.png', locked: false },
];

export default function FailedRemoveAction() {
  const [files, setFiles] = useState(initialFiles);
  const [error, setError] = useState('');

  const remove = (name: string, locked: boolean) => {
    if (locked) {
      setError(name);
      return;
    }

    setError('');
    setFiles(files.filter((file) => file.name !== name));
  };

  return (
    <ul
      style={{
        display: 'flex',
        flexDirection: 'column',
        margin: 0,
        padding: 'var(--spacing-2) var(--spacing-6)',
        listStyle: 'none',
        width: '100%',
        boxSizing: 'border-box',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
        background: 'var(--surface-primary)',
      }}
    >
      {files.map((file, index) => {
        const failed = error === file.name;
        const errorId = `remove-error-${file.name}`;

        return (
          <li
            key={file.name}
            style={{
              display: 'flex',
              flexDirection: 'column',
              gap: 'var(--spacing-1)',
              paddingBlock: 'var(--spacing-3)',
              borderBlockStart: index === 0 ? undefined : '1px solid var(--separator-opaque)',
            }}
          >
            <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
              <Icon size='sm' style={{ flexShrink: 0, color: 'var(--label-secondary)' }}><FileTextIcon /></Icon>
              <span style={{ flex: 1, minWidth: 0, overflowWrap: 'anywhere' }}>{file.name}</span>
              <IconButton
                type='button'
                size='sm'
                variant='secondary'
                appearance='transparent'
                aria-label={`Remove ${file.name}`}
                aria-describedby={failed ? errorId : undefined}
                onClick={() => remove(file.name, file.locked)}
              >
                <Icon><Trash2Icon /></Icon>
              </IconButton>
            </div>
            {failed && (
              <HelperText id={errorId} error role='alert' style={{ paddingInlineStart: 'calc(var(--spacing-8) + var(--spacing-4))', paddingBlockEnd: 'var(--spacing-2)' }}>
                Signed contracts cannot be removed. Ask an admin to archive it instead.
              </HelperText>
            )}
          </li>
        );
      })}
    </ul>
  );
}

Отключённый

Отключённый: панель инструментов, отключённая через fieldset

<Fieldset disabled> нативно отключает каждый IconButton внутри — удобно для документов только для чтения, где недоступна вся панель сразу.

blocked-icon-action.tsx · 32 строки
import { Button, Fieldset, FieldsetLegend, Icon, IconButton } from '@vega-ui/react';
import { BoldIcon, ItalicIcon, LinkIcon, ListIcon } from '@vega-ui/icons';
import { useState } from 'react';

export default function BlockedIconAction() {
  const [locked, setLocked] = useState(true);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 'var(--spacing-8)', width: '100%' }}>
      <Fieldset disabled={locked} appearance='outlined' style={{ width: '100%', boxSizing: 'border-box' }}>
        <FieldsetLegend>{locked ? 'Formatting (read-only document)' : 'Formatting'}</FieldsetLegend>
        <div style={{ display: 'flex', gap: 'var(--spacing-2)' }}>
          <IconButton type='button' size='sm' variant='secondary' appearance='ghost' aria-label='Bold'>
            <Icon><BoldIcon /></Icon>
          </IconButton>
          <IconButton type='button' size='sm' variant='secondary' appearance='ghost' aria-label='Italic'>
            <Icon><ItalicIcon /></Icon>
          </IconButton>
          <IconButton type='button' size='sm' variant='secondary' appearance='ghost' aria-label='Insert link'>
            <Icon><LinkIcon /></Icon>
          </IconButton>
          <IconButton type='button' size='sm' variant='secondary' appearance='ghost' aria-label='Bulleted list'>
            <Icon><ListIcon /></Icon>
          </IconButton>
        </div>
      </Fieldset>
      <Button type='button' size='sm' variant='secondary' appearance='ghost' onClick={() => setLocked(!locked)}>
        {locked ? 'Request edit access' : 'Switch to viewing'}
      </Button>
    </div>
  );
}

Пограничный случай

Пограничный случай: кнопки масштаба на границах

Кнопки отключаются на первом и последнем шаге масштаба, вычисленных из списка шагов. Когда кнопка отключает саму себя в фокусе, браузер теряет фокус, поэтому значение стоит между кнопками, где пользователь его видит.

zoom-controls.tsx · 41 строка
import { Icon, IconButton } from '@vega-ui/react';
import { MinusIcon, PlusIcon } from '@vega-ui/icons';
import { useState } from 'react';

const steps = [25, 50, 75, 100, 150, 200, 400];

export default function ZoomControls() {
  const [index, setIndex] = useState(3);
  const atMin = index === 0;
  const atMax = index === steps.length - 1;

  return (
    <div role='group' aria-label='Zoom' style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
      <IconButton
        type='button'
        size='sm'
        variant='secondary'
        appearance='ghost'
        aria-label='Zoom out'
        disabled={atMin}
        onClick={() => setIndex(index - 1)}
      >
        <Icon><MinusIcon /></Icon>
      </IconButton>
      <output aria-live='polite' style={{ minWidth: '4ch', textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
        {steps[index]}%
      </output>
      <IconButton
        type='button'
        size='sm'
        variant='secondary'
        appearance='ghost'
        aria-label='Zoom in'
        disabled={atMax}
        onClick={() => setIndex(index + 1)}
      >
        <Icon><PlusIcon /></Icon>
      </IconButton>
    </div>
  );
}

Анатомия

Обзор

IconButton — одиночная поверхность действия только с иконкой, построенная на ButtonBase. Её анатомия — один интерактивный корень и иконка без видимой текстовой подписи.

Обязательные части

IconButton

Обязательная часть. Отвечает за размер и визуальные пропсы базовой кнопки, такие как вариант и оформление.

Необязательные части

IconButton не предоставляет публичных дочерних частей, но обычно его комбинируют с Icon.

Порядок композиции

  1. IconButton
  2. содержимое-иконка

Допустимые варианты композиции

basic-usage.tsx · 27 строк
import { Icon, IconButton, Text } from '@vega-ui/react';
import { EllipsisIcon, PencilIcon, Share2Icon } from '@vega-ui/icons';
import { useState } from 'react';

export default function BasicUsage() {
  const [lastAction, setLastAction] = useState('');

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-2)', width: '100%' }}>
      <header style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-2)' }}>
        <Text size={4} fontWeight={600} style={{ flex: 1, minWidth: 0 }}>Brand guidelines</Text>
        <IconButton type='button' size='sm' variant='secondary' appearance='transparent' aria-label='Rename document' onClick={() => setLastAction('Rename')}>
          <Icon><PencilIcon /></Icon>
        </IconButton>
        <IconButton type='button' size='sm' variant='secondary' appearance='transparent' aria-label='Share document' onClick={() => setLastAction('Share')}>
          <Icon><Share2Icon /></Icon>
        </IconButton>
        <IconButton type='button' size='sm' variant='secondary' appearance='transparent' aria-label='More actions' onClick={() => setLastAction('More')}>
          <Icon><EllipsisIcon /></Icon>
        </IconButton>
      </header>
      <Text size={2} style={{ color: 'var(--label-secondary)' }}>
        {lastAction === '' ? 'Updated 2 hours ago' : `Clicked: ${lastAction}`}
      </Text>
    </div>
  );
}

Недопустимые варианты композиции

Кнопка только с иконкой без доступной подписи

Действие становится непонятным для вспомогательных технологий, а часто и для зрячих пользователей.

Длинный текст внутри IconButton

Если действию нужны видимые слова, используйте Button.