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
- Размер в бандле
- Протестирован в
- 15 тестов · 6 нояб.
- История версий
- Добавлен в v1.0.0, последнее изменение в v3.0.0
Кратко
IconButton — вариант действия только с иконкой, построенный на той же основе, что и Button. Это компактный слой действий для случаев, когда видимый текст добавил бы шум, а не ясность.
Импорты
import {
IconButton,
type IconButtonProps,
type IconButtonSize,
} from '@vega-ui/react';Экспортируемые типы
IconButtonPropsIconButtonSize
Базовое использование
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.
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; вложенная иконка занимает треть размера кнопки.
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, кнопка-переключатель, нажата». Если менять подпись при каждом клике, состояние, наоборот, потеряется.
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»: одна только смена иконки для скринридера беззвучна.
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.
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, позиционированная логическими отступами, чтобы зеркалиться в раскладках справа налево.
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 показывает то же имя и сочетание клавиш пользователям мыши и клавиатуры. Тултип связан как описание, поэтому дополнительные детали держите в нём.
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. Доступное имя включает имя файла, поэтому десять одинаковых иконок корзины всё равно различимы.
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 внутри — удобно для документов только для чтения, где недоступна вся панель сразу.
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>
);
}Пограничный случай
Пограничный случай: кнопки масштаба на границах
Кнопки отключаются на первом и последнем шаге масштаба, вычисленных из списка шагов. Когда кнопка отключает саму себя в фокусе, браузер теряет фокус, поэтому значение стоит между кнопками, где пользователь его видит.
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.
Порядок композиции
IconButton- содержимое-иконка
Допустимые варианты композиции
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.