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

Link

Link — компонент текстового навигационного действия для встроенных и самостоятельных переходов.
link.tsx · 3 строки
import { Link } from '@vega-ui/react';

<Link href='#'>Billing settings</Link>
Поддержка браузеров
Широко доступно
  • Chrome 111
  • Edge 111
  • Firefox 113
  • Safari 15.4
  • Safari на iOS 15.4
Размер в бандле
История версий
Добавлен в v1.0.0

Кратко

Link — компонент текстового навигационного действия для встроенных и самостоятельных переходов. Это выбор по умолчанию, когда взаимодействие меняет местоположение, а не изменяет состояние.

Импорт

import { Link, type LinkProps } from '@vega-ui/react';

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

  • LinkProps

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

basic-usage.tsx · 11 строк
import { Link, Text } from '@vega-ui/react';

export default function BasicUsage() {
  return (
    <Text size={3} style={{ width: '100%' }}>
      Exports include every invoice from the selected period. Read how we calculate{' '}
      <Link href='/docs/billing/taxes'>VAT for EU customers</Link>{' '}
      before sharing the file with your accountant.
    </Text>
  );
}

Варианты

  • Встроенная текстовая ссылка или самостоятельное навигационное действие
  • Рендер якоря по умолчанию или композиция через asChild

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

  • Используйте настоящие цели навигации, а не поведение кнопки, замаскированное под ссылку.
  • asChild полезен для интеграции с роутерами при сохранении стилизации ссылки.
  • Проверяйте поведение фокуса и подчёркивания в сочетании с окружающей типографикой.
  • Если нужна выразительная поверхность призыва к действию, Button asChild может быть понятнее обычной текстовой ссылки.

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

  • Использование Link для действий, изменяющих состояние.
  • Стилизация ссылки под кнопку без учёта семантики.
  • Расплывчатый текст ссылки вроде «Нажмите здесь».

Примеры

Базовый

Самостоятельная ссылка называет место назначения («View all 48 invoices»), а не «нажмите здесь». Стрелка декоративная: Icon скрывает её от скринридера.

standalone-navigation-link.tsx · 38 строк
import { Heading, Icon, Link } from '@vega-ui/react';
import { ArrowRightIcon } from '@vega-ui/icons';
import './examples.css';

const invoices = [
  { id: 'INV-2041', amount: '$1,240.00' },
  { id: 'INV-2040', amount: '$860.50' },
  { id: 'INV-2039', amount: '$2,115.00' },
];

export default function StandaloneNavigationLink() {
  return (
    <section aria-labelledby='recent-invoices' style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)', width: '100%' }}>
      <Heading id='recent-invoices' as='h3' size={4} fontWeight={600}>Recent invoices</Heading>
      <ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
        {invoices.map((invoice) => (
          <li
            key={invoice.id}
            style={{
              display: 'flex',
              justifyContent: 'space-between',
              gap: 'var(--spacing-8)',
              paddingBlock: 'var(--spacing-4)',
              borderBlockEnd: '1px solid var(--separator-opaque)',
            }}
          >
            <span>{invoice.id}</span>
            <span style={{ fontVariantNumeric: 'tabular-nums' }}>{invoice.amount}</span>
          </li>
        ))}
      </ul>
      <Link href='/billing/invoices' style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--spacing-2)', alignSelf: 'flex-start' }}>
        View all 48 invoices
        <Icon size='xs' className='directionalIcon'><ArrowRightIcon /></Icon>
      </Link>
    </section>
  );
}

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

Link asChild передаёт свои стили ссылке роутера. Текущим маршрутом владеет родитель и отмечает активный пункт через aria-current='page'; здесь роутер имитируется через preventDefault.

router-composed-link.tsx · 55 строк
import { type AnchorHTMLAttributes, type MouseEvent, useState } from 'react';
import { Link } from '@vega-ui/react';

interface RouterLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
  to: string
  onNavigate(to: string): void
}

function RouterLink({ to, onNavigate, onClick, ...props }: RouterLinkProps) {
  const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
    onClick?.(event);
    event.preventDefault();
    onNavigate(to);
  };

  return <a href={to} onClick={handleClick} {...props} />;
}

const pages = [
  { to: '/settings/profile', label: 'Profile' },
  { to: '/settings/team', label: 'Team' },
  { to: '/settings/billing', label: 'Billing' },
];

export default function RouterComposedLink() {
  const [pathname, setPathname] = useState('/settings/profile');

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)', width: '100%' }}>
      <nav aria-label='Settings'>
        <ul style={{ display: 'flex', gap: 'var(--spacing-10)', margin: 0, padding: 0, listStyle: 'none' }}>
          {pages.map((page) => {
            const current = pathname === page.to;

            return (
              <li key={page.to}>
                <Link asChild>
                  <RouterLink
                    to={page.to}
                    onNavigate={setPathname}
                    aria-current={current ? 'page' : undefined}
                    style={current ? { color: 'var(--label-primary)', textDecorationLine: 'none' } : undefined}
                  >
                    {page.label}
                  </RouterLink>
                </Link>
              </li>
            );
          })}
        </ul>
      </nav>
      <p style={{ margin: 0, color: 'var(--label-secondary)' }}>Current route: {pathname}</p>
    </div>
  );
}

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

Ссылка внутри формы выполняет переход и никогда не отправляет форму. Она стоит рядом с подписью пароля, после поля email в порядке табуляции, а форма читает значения через FormData при отправке.

forgot-password-link.tsx · 39 строк
import { Button, Label, Link, TextField, TextFieldInput } from '@vega-ui/react';
import { FormEvent, useState } from 'react';

export default function ForgotPasswordLink() {
  const [signedIn, setSignedIn] = useState('');

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

  return (
    <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-12)', width: '100%' }}>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)' }}>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)' }}>
          <Label htmlFor='sign-in-email'>Email</Label>
          <TextField>
            <TextFieldInput id='sign-in-email' name='email' type='email' autoComplete='email' required />
          </TextField>
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 'var(--spacing-8)' }}>
            <Label htmlFor='sign-in-password'>Password</Label>
            <Link href='/account/reset-password' style={{ fontSize: 'var(--font-size-2)' }}>Forgot password?</Link>
          </div>
          <TextField>
            <TextFieldInput id='sign-in-password' name='password' type='password' autoComplete='current-password' required />
          </TextField>
        </div>
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-4)' }}>
        <Button type='submit' fullWidth>Sign in</Button>
        <output style={{ color: 'var(--label-secondary)', textAlign: 'center', overflowWrap: 'anywhere' }}>
          {signedIn === '' ? '' : `Signed in as ${signedIn}`}
        </output>
      </div>
    </form>
  );
}

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

Ссылка в описании диалога даёт контекст, не становясь одним из его действий. Текст связан с диалогом через aria-describedby.

inline-help-link-inside-dialog-copy.tsx · 52 строки
import type { CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Link,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

export default function InlineHelpLinkInsideDialogCopy() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button>Publish site</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby='publish-title' aria-describedby='publish-description'>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id='publish-title' as='h2' size={4} fontWeight={600}>Publish to acme.design?</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 id='publish-description' size={3}>
          Everyone with the address can see the site. Review the{' '}
          <Link href='/docs/publishing-policy'>publishing policy</Link>{' '}
          if the site contains customer data.
        </Text>
        <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
          <DialogClose asChild>
            <Button variant='secondary' appearance='ghost'>Not now</Button>
          </DialogClose>
          <DialogClose asChild>
            <Button>Publish</Button>
          </DialogClose>
        </div>
      </DialogContent>
    </Dialog>
  );
}

Ошибка

Когда оплата не прошла, сообщение об ошибке содержит выход из ситуации. Сообщение объявляется через role='alert' и связано с кнопкой через aria-describedby.

recovery-link-after-a-failed-flow.tsx · 21 строка
import { Button, HelperText, Link } from '@vega-ui/react';
import { useState } from 'react';

export default function RecoveryLinkAfterAFailedFlow() {
  const [declined, setDeclined] = useState(false);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 'var(--spacing-3)', width: '100%' }}>
      <Button type='button' aria-describedby={declined ? 'payment-error' : undefined} onClick={() => setDeclined(true)}>
        Pay $49.00
      </Button>
      {declined && (
        <HelperText id='payment-error' error role='alert'>
          Your card ending in 4242 was declined.{' '}
          <Link href='/billing/payment-methods'>Update your payment method</Link>{' '}
          and try again.
        </HelperText>
      )}
    </div>
  );
}

Отключённый

Отключённый: обычный текст, пока цели нет

У ссылок нет отключённого состояния, и у Link нет стилей для него. Пока отчёт не готов, выводите текст с объяснением, а когда по ссылке можно перейти — настоящую ссылку.

unavailable-link-as-text.tsx · 22 строки
import { Button, Link } from '@vega-ui/react';
import { useState } from 'react';

export default function UnavailableLinkAsText() {
  const [ready, setReady] = useState(false);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 'var(--spacing-6)', width: '100%' }}>
      <p style={{ margin: 0 }}>
        {ready && <Link href='/reports/2024-q4.pdf'>Download the Q4 report</Link>}
        {!ready && (
          <span style={{ color: 'var(--disable-text-color)' }}>
            Q4 report is being generated and will be available in about 5 minutes
          </span>
        )}
      </p>
      <Button type='button' size='sm' variant='secondary' appearance='ghost' disabled={ready} onClick={() => setReady(true)}>
        Finish generating
      </Button>
    </div>
  );
}

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

target='_blank' меняет контекст без предупреждения, поэтому ссылка сообщает об этом: декоративная иконка для зрячих и примечание в VisuallyHidden для скринридера. rel='noopener noreferrer' не даёт новой странице управлять текущей.

external-link-new-tab.tsx · 18 строк
import { Icon, Link, Text, VisuallyHidden } from '@vega-ui/react';
import { ExternalLinkIcon } from '@vega-ui/icons';

export default function ExternalLinkNewTab() {
  return (
    <Text size={3}>
      Payments are processed by{' '}
      <Link href='https://stripe.com/privacy' target='_blank' rel='noopener noreferrer'>
        Stripe’s privacy policy
        <Icon size='2xs' style={{ marginInlineStart: 'var(--spacing-1)', verticalAlign: 'baseline' }}><ExternalLinkIcon /></Icon>
        <VisuallyHidden asChild>
          <span>(opens in a new tab)</span>
        </VisuallyHidden>
      </Link>
      .
    </Text>
  );
}

Пограничный случай: длинный URL в узком контейнере

В URL нет пробелов для переноса, и он вылезает из узких карточек. overflowWrap: 'anywhere' разрешает перенос на любом символе.

long-url-wrapping.tsx · 14 строк
import { Link, Text } from '@vega-ui/react';

const webhookUrl = 'https://hooks.example.com/services/T024BE7LD/B08S9Q2KJ7H/aD4f9kLq2ZxR7mN3pW8vY1cE';

export default function LongUrlWrapping() {
  return (
    <div style={{ width: 240, padding: 'var(--spacing-8)', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}>
      <Text size={3}>
        Events are sent to{' '}
        <Link href={webhookUrl} style={{ overflowWrap: 'anywhere' }}>{webhookUrl}</Link>
      </Text>
    </div>
  );
}

Анатомия

Обзор

Link — единая текстовая поверхность для навигации. Её анатомия — один корень наподобие якоря плюс встроенное или самостоятельное текстовое содержимое; при желании корень рендерится через asChild.

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

Обязательна. Отвечает за стилизацию ссылки и семантику якоря при прямом рендере.

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

Link не предоставляет публичных дочерних частей.

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

  1. Link
  2. встроенное или самостоятельное текстовое содержимое

Корректные варианты композиции

basic-usage.tsx · 11 строк
import { Link, Text } from '@vega-ui/react';

export default function BasicUsage() {
  return (
    <Text size={3} style={{ width: '100%' }}>
      Exports include every invoice from the selected period. Read how we calculate{' '}
      <Link href='/docs/billing/taxes'>VAT for EU customers</Link>{' '}
      before sharing the file with your accountant.
    </Text>
  );
}

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

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

Встроенный текст вроде «Нажмите здесь» ухудшает просматриваемость и доступность.