import { Icon, IconButton, VisuallyHidden } from '@vega-ui/react';
import { Search } from '@vega-ui/icons';
<IconButton variant='secondary' appearance='ghost'>
<Icon><Search /></Icon>
<VisuallyHidden>Search</VisuallyHidden>
</IconButton>- Поддержка браузеров
- Широко доступно
- Chrome 111
- Edge 111
- Firefox 113
- Safari 15.4
- Safari на iOS 15.4
- Размер в бандле
- Протестирован в
- 7 тестов · 6 нояб.
- История версий
- Добавлен в v1.0.0
- Oklab and OkLCh — приходит из дизайн-токенов
- clip-path
Кратко
VisuallyHidden оставляет содержимое доступным для вспомогательных технологий, убирая его из визуальной раскладки. Поддерживает нативную обёртку или композицию через asChild с помощью Slot.
Импорт
import { VisuallyHidden, type VisuallyHiddenProps } from '@vega-ui/react';Экспортируемые типы
VisuallyHiddenProps
Базовое использование
import { Heading, Link, Text, VisuallyHidden } from '@vega-ui/react';
const plans = [
{ name: 'Starter', price: '$0', slug: 'starter' },
{ name: 'Team', price: '$12', slug: 'team' },
{ name: 'Enterprise', price: 'Custom', slug: 'enterprise' },
];
export default function BasicUsage() {
return (
<ul style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 'var(--spacing-4)', width: '100%', margin: 0, padding: 0, listStyle: 'none' }}>
{plans.map((plan) => (
<li
key={plan.slug}
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing-2)',
padding: 'var(--spacing-6)',
border: '1px solid var(--separator-opaque)',
borderRadius: 'var(--radius-4)',
background: 'var(--surface-primary)',
}}
>
<Heading as='h3' size={3} fontWeight={600}>{plan.name}</Heading>
<Text size={3} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{plan.price}</Text>
<Link href={`/pricing/${plan.slug}`} style={{ marginBlockStart: 'var(--spacing-4)', fontSize: 'var(--font-size-2)' }}>
Learn more
<VisuallyHidden asChild>
<span> about the {plan.name} plan</span>
</VisuallyHidden>
</Link>
</li>
))}
</ul>
);
}Варианты
- Рендер: обёртка по умолчанию или
asChild - Применение: скрытая подпись, описание или текст статуса
Пограничные случаи
- Скрытый текст должен оставаться синхронизированным с видимым состоянием интерфейса.
VisuallyHiddenпо умолчанию не заменяет видимые подписи.asChildсохраняет стили скрытия, но меняет нижележащий элемент.
Частые ошибки
- Скрытый текст устаревает, когда меняется видимое состояние.
- Скрытый текст маскирует плохие видимые подписи.
- Скрыто содержимое, которое на самом деле должно быть видно большинству пользователей.
Примеры
Базовый
Базовый: уникальные имена для повторяющихся ссылок
Три ссылки «Learn more» в списке ссылок звучат одинаково. Скрытый текст дополняет каждое имя («Learn more about the Team plan»), не меняя раскладку.
import { Heading, Link, Text, VisuallyHidden } from '@vega-ui/react';
const plans = [
{ name: 'Starter', price: '$0', slug: 'starter' },
{ name: 'Team', price: '$12', slug: 'team' },
{ name: 'Enterprise', price: 'Custom', slug: 'enterprise' },
];
export default function BasicUsage() {
return (
<ul style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 'var(--spacing-4)', width: '100%', margin: 0, padding: 0, listStyle: 'none' }}>
{plans.map((plan) => (
<li
key={plan.slug}
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing-2)',
padding: 'var(--spacing-6)',
border: '1px solid var(--separator-opaque)',
borderRadius: 'var(--radius-4)',
background: 'var(--surface-primary)',
}}
>
<Heading as='h3' size={3} fontWeight={600}>{plan.name}</Heading>
<Text size={3} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{plan.price}</Text>
<Link href={`/pricing/${plan.slug}`} style={{ marginBlockStart: 'var(--spacing-4)', fontSize: 'var(--font-size-2)' }}>
Learn more
<VisuallyHidden asChild>
<span> about the {plan.name} plan</span>
</VisuallyHidden>
</Link>
</li>
))}
</ul>
);
}Базовый: кнопка-иконка с текстовым именем
Скрытый текст внутри кнопки становится её доступным именем и может содержать данные, которые бейдж показывает лишь числом. Сам бейдж помечен aria-hidden, чтобы число не читалось дважды. asChild рендерит <span>, потому что <div> по умолчанию недопустим внутри кнопки.
import { Icon, IconButton, VisuallyHidden } from '@vega-ui/react';
import { BellIcon } from '@vega-ui/icons';
import { useState } from 'react';
export default function IconOnlyControlLabel() {
const [unread, setUnread] = useState(4);
return (
<IconButton type='button' variant='secondary' appearance='ghost' onClick={() => setUnread(0)} style={{ position: 'relative' }}>
<Icon><BellIcon /></Icon>
<VisuallyHidden asChild>
<span>Notifications, {unread} unread</span>
</VisuallyHidden>
{unread > 0 && (
<span
aria-hidden='true'
style={{
position: 'absolute',
insetBlockStart: 'var(--spacing-1)',
insetInlineEnd: 'var(--spacing-1)',
minWidth: 'var(--spacing-8)',
paddingInline: 'var(--spacing-2)',
boxSizing: 'border-box',
borderRadius: 999,
background: 'var(--color-error)',
color: 'var(--color-white)',
fontSize: 'var(--font-size-1)',
lineHeight: 'var(--spacing-8)',
textAlign: 'center',
fontVariantNumeric: 'tabular-nums',
}}
>
{unread}
</span>
)}
</IconButton>
);
}Контролируемый/с состоянием
Контролируемый/с состоянием: живое объявление для визуального счётчика
Счётчик корзины меняется беззвучно для скринридера. Скрытая область role='status' получает полное предложение при каждом изменении, а визуальный счётчик остаётся aria-hidden.
import { Button, Icon, VisuallyHidden } from '@vega-ui/react';
import { ShoppingCartIcon } from '@vega-ui/icons';
import { useState } from 'react';
const products = ['Linen shirt', 'Canvas tote', 'Wool socks'];
export default function HiddenStatusText() {
const [cart, setCart] = useState<string[]>([]);
const [announcement, setAnnouncement] = useState('');
const add = (product: string) => {
const next = [...cart, product];
setCart(next);
setAnnouncement(`${product} added. Cart has ${next.length} items.`);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 'var(--spacing-8)', width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 'var(--spacing-4)' }}>
{products.map((product) => (
<Button key={product} type='button' size='sm' variant='secondary' appearance='ghost' onClick={() => add(product)}>
Add {product}
</Button>
))}
</div>
<span aria-hidden='true' style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-3)', fontVariantNumeric: 'tabular-nums' }}>
<Icon size='sm'><ShoppingCartIcon /></Icon>
{cart.length}
</span>
<VisuallyHidden asChild>
<p role='status'>{announcement}</p>
</VisuallyHidden>
</div>
);
}Форма/интеграция
Форма/интеграция: скрытое описание визуального украшения
Префикс $ только визуальный. Скрытое описание, связанное через aria-describedby, сообщает скринридеру валюту; значение читается через FormData при отправке. VisuallyHidden не принимает id, поэтому id задаётся на span через asChild.
import { Button, Label, TextField, TextFieldInput, VisuallyHidden } from '@vega-ui/react';
import { FormEvent, useState } from 'react';
export default function HiddenHelperDescription() {
const [budget, setBudget] = useState('');
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setBudget(String(new FormData(event.currentTarget).get('budget')));
};
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%' }}>
<Label htmlFor='monthly-budget'>Monthly budget</Label>
<TextField style={{ paddingInlineStart: 'var(--spacing-8)', gap: 'var(--spacing-2)' }}>
<span aria-hidden='true' style={{ color: 'var(--label-secondary)' }}>$</span>
<TextFieldInput
id='monthly-budget'
name='budget'
type='number'
min={0}
step={50}
defaultValue={500}
aria-describedby='monthly-budget-currency'
required
style={{ paddingInlineStart: 0, fontVariantNumeric: 'tabular-nums' }}
/>
</TextField>
<VisuallyHidden asChild>
<span id='monthly-budget-currency'>Amount in US dollars</span>
</VisuallyHidden>
<Button type='submit' style={{ alignSelf: 'flex-start', marginBlockStart: 'var(--spacing-9)' }}>Save budget</Button>
<output style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>
{budget === '' ? '' : `Budget set to $${budget}`}
</output>
</form>
);
}Раскладка/оверлей
Раскладка/оверлей: скрытый заголовок диалога
У лайтбокса нет видимого заголовка, но диалогу всё равно нужно имя. Скрытый <h2> подключён через aria-labelledby, поэтому диалог объявляется как «Floor plan, apartment 4B».
import type { CSSProperties } from 'react';
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogTrigger,
Icon,
IconButton,
VisuallyHidden,
} from '@vega-ui/react';
import { ImageIcon, XIcon } from '@vega-ui/icons';
export default function HiddenDialogCue() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant='secondary' appearance='ghost'>
<Icon size='sm'><ImageIcon /></Icon>
View floor plan
</Button>
</DialogTrigger>
<DialogContent aria-labelledby='floor-plan-title'>
<VisuallyHidden asChild>
<h2 id='floor-plan-title'>Floor plan, apartment 4B</h2>
</VisuallyHidden>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<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>
<div
role='img'
aria-label='Two bedrooms, open kitchen, balcony facing south, 74 square meters'
style={{
display: 'grid',
gridTemplateColumns: '2fr 1fr',
gap: 'var(--spacing-2)',
height: 220,
marginBlockStart: 'var(--spacing-8)',
}}
>
<div style={{ background: 'var(--fills-secondary)', borderRadius: 'var(--radius-2)' }} />
<div style={{ display: 'grid', gap: 'var(--spacing-2)' }}>
<div style={{ background: 'var(--fills-tertiary)', borderRadius: 'var(--radius-2)' }} />
<div style={{ background: 'var(--fills-primary)', borderRadius: 'var(--radius-2)' }} />
</div>
</div>
</DialogContent>
</Dialog>
);
}Ошибка
Ошибка: озвучиваемый префикс сообщения об ошибке
Ошибка показана цветом и иконкой. Скрытый префикс «Error:» даёт тот же сигнал пользователям скринридера, когда они попадают на сообщение через aria-describedby.
import { Button, HelperText, Icon, Label, TextField, TextFieldInput, VisuallyHidden } from '@vega-ui/react';
import { CircleAlertIcon } from '@vega-ui/icons';
import { FormEvent, useState } from 'react';
export default function HiddenValidationCue() {
const [error, setError] = useState('');
const [saved, setSaved] = useState('');
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const domain = String(new FormData(event.currentTarget).get('domain'));
if (!domain.includes('.')) {
setError('Add a top-level domain, for example acme.com');
setSaved('');
return;
}
setError('');
setSaved(domain);
};
return (
<form noValidate onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%' }}>
<Label htmlFor='custom-domain'>Custom domain</Label>
<TextField error={error !== ''}>
<TextFieldInput
id='custom-domain'
name='domain'
defaultValue='acme'
aria-invalid={error !== ''}
aria-describedby={error === '' ? undefined : 'custom-domain-error'}
/>
</TextField>
{error !== '' && (
<HelperText id='custom-domain-error' error style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-3)' }}>
<Icon size='xs' style={{ flexShrink: 0 }}><CircleAlertIcon /></Icon>
<VisuallyHidden asChild>
<span>Error:</span>
</VisuallyHidden>
{error}
</HelperText>
)}
<Button type='submit' style={{ alignSelf: 'flex-start', marginBlockStart: 'var(--spacing-9)' }}>Connect domain</Button>
<output style={{ color: 'var(--label-secondary)' }}>{saved === '' ? '' : `Connected ${saved}`}</output>
</form>
);
}Отключённый
Отключённый: почему вариант недоступен
Распроданные размеры отключены и зачёркнуты. Зачёркивание только визуальное, поэтому скрытый текст добавляет «, sold out» к имени кнопки — скринридер читает его и в режиме просмотра.
import { Button, VisuallyHidden } from '@vega-ui/react';
import { useState } from 'react';
const sizes = [
{ label: 'S', inStock: true },
{ label: 'M', inStock: true },
{ label: 'L', inStock: false },
{ label: 'XL', inStock: false },
];
export default function HiddenDisabledExplanation() {
const [selected, setSelected] = useState('M');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-6)', width: '100%' }}>
<div role='group' aria-label='Size' style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
{sizes.map((size) => (
<Button
key={size.label}
type='button'
size='sm'
variant={selected === size.label ? 'primary' : 'secondary'}
appearance='ghost'
aria-pressed={selected === size.label}
disabled={!size.inStock}
onClick={() => setSelected(size.label)}
style={{ textDecoration: size.inStock ? 'none' : 'line-through' }}
>
{size.label}
{!size.inStock && (
<VisuallyHidden asChild>
<span>, sold out</span>
</VisuallyHidden>
)}
</Button>
))}
</div>
<p style={{ margin: 0, color: 'var(--label-secondary)' }}>Selected size: {selected}</p>
</div>
);
}Пограничный случай
Пограничный случай: текстовая альтернатива рейтинга из символов
Символы звёзд читаются по одному как «чёрная звезда». Они скрыты через aria-hidden, а скрытый span внутри абзаца читается как «Rated 3 out of 5». asChild оставляет <span> внутри <p>, где <div> недопустим.
import { Text, VisuallyHidden } from '@vega-ui/react';
const reviews = [
{ author: 'Maria K.', rating: 5, text: 'Arrived in two days, fits perfectly.' },
{ author: 'Tom B.', rating: 3, text: 'Good fabric, but the sleeves run long.' },
];
export default function SlottedHiddenElement() {
return (
<ul style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)', width: '100%', margin: 0, padding: 0, listStyle: 'none' }}>
{reviews.map((review) => (
<li key={review.author} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-1)' }}>
<Text size={3} fontWeight={500}>{review.author}</Text>
<p style={{ margin: 0 }}>
<span aria-hidden='true' style={{ color: 'var(--color-primary-500)', letterSpacing: 'var(--spacing-1)' }}>
{'★'.repeat(review.rating)}{'☆'.repeat(5 - review.rating)}
</span>
<VisuallyHidden asChild>
<span>Rated {review.rating} out of 5</span>
</VisuallyHidden>
</p>
<Text size={3} style={{ color: 'var(--label-secondary)' }}>{review.text}</Text>
</li>
))}
</ul>
);
}Пограничный случай: пустой на вид заголовок таблицы
У колонки действий часто нет видимого заголовка, но пустой <th> оставляет ячейки без контекста. Скрытый заголовок «Actions» решает это, а колонка визуально остаётся пустой.
import { Icon, IconButton, VisuallyHidden } from '@vega-ui/react';
import { PencilIcon } from '@vega-ui/icons';
import { useState } from 'react';
const members = [
{ name: 'Jane Cooper', role: 'Owner' },
{ name: 'Wade Warren', role: 'Editor' },
{ name: 'Esther Howard', role: 'Viewer' },
];
export default function HiddenTableHeader() {
const [editing, setEditing] = useState('');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-6)', width: '100%' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th scope='col' style={{ paddingBlock: 'var(--spacing-3)', borderBlockEnd: '1px solid var(--separator-opaque)', textAlign: 'start' }}>Name</th>
<th scope='col' style={{ paddingBlock: 'var(--spacing-3)', borderBlockEnd: '1px solid var(--separator-opaque)', textAlign: 'start' }}>Role</th>
<th scope='col' style={{ paddingBlock: 'var(--spacing-3)', borderBlockEnd: '1px solid var(--separator-opaque)' }}>
<VisuallyHidden>Actions</VisuallyHidden>
</th>
</tr>
</thead>
<tbody>
{members.map((member) => (
<tr key={member.name}>
<td style={{ paddingBlock: 'var(--spacing-3)', borderBlockEnd: '1px solid var(--separator-opaque)' }}>{member.name}</td>
<td style={{ paddingBlock: 'var(--spacing-3)', borderBlockEnd: '1px solid var(--separator-opaque)', color: 'var(--label-secondary)' }}>{member.role}</td>
<td style={{ paddingBlock: 'var(--spacing-3)', borderBlockEnd: '1px solid var(--separator-opaque)', textAlign: 'end' }}>
<IconButton
style={{ display: 'inline-flex' }}
type='button'
size='xs'
variant='secondary'
appearance='transparent'
aria-label={`Edit ${member.name}`}
onClick={() => setEditing(member.name)}
>
<Icon><PencilIcon /></Icon>
</IconButton>
</td>
</tr>
))}
</tbody>
</table>
<p style={{ margin: 0, color: 'var(--label-secondary)' }}>{editing === '' ? '' : `Editing ${editing}`}</p>
</div>
);
}Анатомия
Обзор
VisuallyHidden — примитив из одной части, который применяет к своему содержимому стандартный CSS-паттерн визуального скрытия.
Обязательные части
VisuallyHidden
Обязательна. Отвечает за рендер, при котором содержимое скрыто визуально, но остаётся доступным.
Необязательные части
Экспортируемых дочерних частей нет.
Порядок композиции
VisuallyHidden- скрытое содержимое
Допустимые паттерны композиции
import { Heading, Link, Text, VisuallyHidden } from '@vega-ui/react';
const plans = [
{ name: 'Starter', price: '$0', slug: 'starter' },
{ name: 'Team', price: '$12', slug: 'team' },
{ name: 'Enterprise', price: 'Custom', slug: 'enterprise' },
];
export default function BasicUsage() {
return (
<ul style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 'var(--spacing-4)', width: '100%', margin: 0, padding: 0, listStyle: 'none' }}>
{plans.map((plan) => (
<li
key={plan.slug}
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing-2)',
padding: 'var(--spacing-6)',
border: '1px solid var(--separator-opaque)',
borderRadius: 'var(--radius-4)',
background: 'var(--surface-primary)',
}}
>
<Heading as='h3' size={3} fontWeight={600}>{plan.name}</Heading>
<Text size={3} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{plan.price}</Text>
<Link href={`/pricing/${plan.slug}`} style={{ marginBlockStart: 'var(--spacing-4)', fontSize: 'var(--font-size-2)' }}>
Learn more
<VisuallyHidden asChild>
<span> about the {plan.name} plan</span>
</VisuallyHidden>
</Link>
</li>
))}
</ul>
);
}Недопустимые паттерны композиции
Скрытый текст по умолчанию вместо видимой подписи
Интерфейсом становится труднее пользоваться зрячим пользователям.
Скрытое содержимое не синхронизировано с видимым состоянием
Пользователи вспомогательных технологий получают устаревшую информацию.