GrLink
A navigation link for moving between pages and resources.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- a transition is needed — a link changes the address, and opening in a new tab has to work;
- the router is your own —
asreplaces the root tag withRouterLinkorNuxtLinkwithout a wrapper; - the link leads outside —
externaladds a sign and an “in a new tab” label for a screen reader; - the link lives inside text —
underlineandtonekeep it recognisable inside a paragraph.
When to take something else
| Need | Take |
|---|---|
| The action changes state rather than the address | GrButton |
| The link looks like a button | GrButton with as/href |
| A list of transitions | GrSidebar / GrDropdownMenu |
| The path to the current page | GrBreadcrumbs |
A button changes state, a link changes the address. Substituting one for the other for the sake of appearance is not allowed: on a link the middle mouse button, the context menu and “open in a new tab” work, on a button they do not, and the user is the first to notice.
The root tag
| Condition | What is rendered |
|---|---|
as is set and it is not disabled | <component :is="as"> — RouterLink, Inertia’s Link, any of your own |
href is set and it is not disabled | <a> |
everything else, disabled included | <span> |
A disabled link deliberately stops being an <a>: that way it is not clickable and does not
take part in the tab order. External CSS selectors have to take that into account.
The attributes of a router (method, replace, preserve-scroll) go through as
fallthrough — the component does not enumerate them.
A new tab is announced
A link that opens in a new tab gets two things:
- the external-link icon — a visible sign;
- a hidden “(opens in a new tab)” suffix — a warning about the change of context (WCAG 3.2.5); without it the move to a new tab happens with no warning for a blind user.
The condition is the actual behaviour of the link (target="_blank") rather than the
external prop: a target set from the outside gives exactly the same surprise. For the same
reason the component sets rel="noopener noreferrer" on any link with _blank, not only on
an external one.
<GrLink href="https://example.com" external>
Documentation
</GrLink>
<GrLink href="https://example.com" target="_blank">
The same thing
</GrLink>
<!-- The icon can be switched off or, on the contrary, switched on for an internal link. -->
<GrLink href="https://example.com" external :external-icon="false">
Without an icon
</GrLink>
<GrLink href="/inner" external-icon>
With an icon
</GrLink>
newTabLabel overrides the text of the hint, and the locale key is gr.link.opensInNewTab.
ariaLabel and the hint
aria-label overrides the content of the element as a whole — the hidden suffix included. If
the name has been set by hand, the hint is therefore appended to it:
aria-label="Documentation" on an external link turns into
"Documentation, opens in a new tab", and a separate sr-only suffix is not rendered, so
that a screen reader does not read it twice.
The colour: `tone` × `variant`
The axes are orthogonal. tone is a colour from the shared palette (primary, neutral,
success, warning, danger, info, slate, azure), and variant is the level of
accent: default (coloured at rest) or muted (dimmed, coloured on hover).
The colour is passed through CSS variables (--gr-link-color, -hover, -active) rather
than through classes for every combination: 8 tones × 3 states would give a class explosion,
while the safelist stays tiny.
The tones are taken from the -text roles rather than from the saturated --gr-{tone}: a
link is text on the background of the page, and --gr-success on --gr-bg gives 2.2:1.
Underlining and disabled
underline: auto (appears on hover), always, none.
A disabled link is dimmed with the --gr-muted-fg colour rather than with opacity:
transparency dilutes a token tuned to AA and drops the contrast below the norm.
Playground 10
Loading…
<GrLink />Install
npm i @feugene/granularityImport
import { GrLink } from '@feugene/granularity/components/GrLink'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | "primary" | The semantic colour of the link from the `GrTone` palette. |
variant | GrLinkVariant | undefined | "default" | The level of accent: `default` (coloured) or `muted` (dimmed, the accent on hover). |
disabled | boolean | undefined | false | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
as | string | Component | undefined | undefined | A custom root tag or component. If it is passed and the component is not `disabled`, it renders through `<component :is="as">`. It is ignored with `disabled`. |
href | string | undefined | undefined | — |
target | string | undefined | undefined | — |
rel | string | undefined | undefined | — |
external | boolean | undefined | false | — |
underline | GrLinkUnderline | undefined | "auto" | — |
externalIcon | boolean | undefined | undefined | The external-link icon. By default it is shown on any link that opens in a new tab — not only with `external`. |
newTabLabel | string | undefined | undefined | i18n: the hidden hint "opens in a new tab". |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The text of the link. |
Examples 4
Interactive link constructor
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
GrFormField,
GrInput,
GrLink,
GrRadioGroup,
GrSelect,
GrSwitch,
type GrLinkSize,
type GrLinkTone,
type GrLinkUnderline,
type GrLinkVariant,
} from '@feugene/granularity'
import CodeBlock from '../../../components/doc/CodeBlock.vue'
type GrLinkTargetMode = 'auto' | '_self' | '_blank' | 'custom'
type GrLinkRelMode = 'auto' | 'noopener noreferrer' | 'nofollow' | 'custom'
const tone = ref<GrLinkTone>('primary')
const variant = ref<GrLinkVariant>('default')
const size = ref<GrLinkSize>('md')
const underline = ref<GrLinkUnderline>('auto')
const label = ref('Open workspace settings')
const href = ref('/settings/workspace')
const ariaLabel = ref('Open workspace settings')
const external = ref(false)
const disabled = ref(false)
const targetMode = ref<GrLinkTargetMode>('auto')
const customTarget = ref('')
const relMode = ref<GrLinkRelMode>('auto')
const customRel = ref('')
const toneOptions = [
{ value: 'primary', label: 'Primary' },
{ value: 'neutral', label: 'Neutral' },
{ value: 'success', label: 'Success' },
{ value: 'warning', label: 'Warning' },
{ value: 'danger', label: 'Danger' },
{ value: 'info', label: 'Info' },
{ value: 'slate', label: 'Slate' },
{ value: 'azure', label: 'Azure' },
] satisfies Array<{ value: GrLinkTone, label: string }>
const variantOptions = [
{ value: 'default', label: 'Default' },
{ value: 'muted', label: 'Muted' },
] satisfies Array<{ value: GrLinkVariant, label: string }>
const sizeOptions = [
{ value: 'sm', label: 'SM' },
{ value: 'md', label: 'MD' },
{ value: 'lg', label: 'LG' },
] satisfies Array<{ value: GrLinkSize, label: string }>
const underlineOptions = [
{ value: 'auto', label: 'Auto' },
{ value: 'always', label: 'Always' },
{ value: 'none', label: 'None' },
] satisfies Array<{ value: GrLinkUnderline, label: string }>
const targetOptions = [
{ value: 'auto', label: 'Auto' },
{ value: '_self', label: '_self' },
{ value: '_blank', label: '_blank' },
{ value: 'custom', label: 'Custom' },
] satisfies Array<{ value: GrLinkTargetMode, label: string }>
const relOptions = [
{ value: 'auto', label: 'Auto' },
{ value: 'noopener noreferrer', label: 'noopener noreferrer' },
{ value: 'nofollow', label: 'nofollow' },
{ value: 'custom', label: 'Custom' },
] satisfies Array<{ value: GrLinkRelMode, label: string }>
const linkText = computed(() => {
return label.value.trim() || 'Open workspace settings'
})
const effectiveAriaLabel = computed(() => {
return ariaLabel.value.trim() || linkText.value
})
const resolvedHref = computed(() => {
return href.value.trim() || '/settings/workspace'
})
const resolvedTarget = computed(() => {
if (targetMode.value === 'custom')
return customTarget.value.trim() || undefined
if (targetMode.value === 'auto')
return undefined
return targetMode.value
})
const resolvedRel = computed(() => {
if (relMode.value === 'custom')
return customRel.value.trim() || undefined
if (relMode.value === 'auto')
return undefined
return relMode.value
})
const previewSummary = computed(() => {
if (disabled.value)
return 'Disabled link рендерится как неинтерактивный inline-элемент и сохраняет типографику текста.'
if (external.value)
return 'External mode автоматически проставляет `target="_blank"` и `rel="noopener noreferrer"`, если вручную их не переопределять.'
if (underline.value === 'always')
return 'Always underline подходит для важных inline-actions, которые должны быть заметны даже без hover.'
return 'Настройте tone, size, underline и навигационные атрибуты, чтобы быстро собрать нужный contract ссылки.'
})
function escapeAttribute(value: string) {
return value.replaceAll('&', '&').replaceAll('"', '"')
}
const previewCode = computed(() => {
const attributes = [
`href="${escapeAttribute(resolvedHref.value)}"`,
`tone="${tone.value}"`,
`variant="${variant.value}"`,
`size="${size.value}"`,
`underline="${underline.value}"`,
]
if (external.value)
attributes.push('external')
if (disabled.value)
attributes.push('disabled')
if (resolvedTarget.value)
attributes.push(`target="${escapeAttribute(resolvedTarget.value)}"`)
if (resolvedRel.value)
attributes.push(`rel="${escapeAttribute(resolvedRel.value)}"`)
if (effectiveAriaLabel.value !== linkText.value)
attributes.push(`aria-label="${escapeAttribute(effectiveAriaLabel.value)}"`)
return ['<GrLink', ...attributes.map(attribute => ` ${attribute}`), '>', ` ${linkText.value}`, '</GrLink>'].join('\n')
})
</script>
<template>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_320px]">
<div class="grid gap-4">
<div
class="relative grid min-h-[280px] rounded-[24px] border border-dashed border-[var(--preview-brd)] bg-[image:var(--preview-surface)] p-6 pb-[72px]"
>
<div class="flex h-full flex-col items-center justify-center gap-4 text-center">
<div class="showcase-demo-caption text-xs">
Preview
</div>
<GrLink
:href="resolvedHref"
:tone="tone"
:variant="variant"
:size="size"
:underline="underline"
:external="external"
:disabled="disabled"
:target="resolvedTarget"
:rel="resolvedRel"
:aria-label="effectiveAriaLabel"
>
{{ linkText }}
</GrLink>
<div class="pointer-events-none absolute inset-x-6 bottom-6 flex justify-center border-t border-dashed border-[var(--preview-brd)] pt-2">
<div class="showcase-demo-text max-w-[42ch] text-center text-sm">
{{ previewSummary }}
</div>
</div>
</div>
</div>
<CodeBlock :code="previewCode" language="vue" expanded title="Rendered snippet" />
</div>
<div class="showcase-demo-panel grid gap-4 rounded-[28px] border p-4 lg:p-5">
<div class="showcase-demo-title text-sm font-semibold">
Свойства ссылки
</div>
<div class="grid gap-4">
<GrFormField label="Tone">
<GrSelect v-model="tone" :options="toneOptions" aria-label="Link tone" />
</GrFormField>
<GrFormField label="Variant">
<GrRadioGroup v-model="variant" :options="variantOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="Size">
<GrRadioGroup v-model="size" :options="sizeOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="Underline">
<GrRadioGroup v-model="underline" :options="underlineOptions" variant="button" size="sm" />
</GrFormField>
<GrFormField label="Label">
<GrInput
v-model="label"
placeholder="Open workspace settings"
aria-label="Link label"
/>
</GrFormField>
<GrFormField label="Href">
<GrInput
v-model="href"
placeholder="/settings/workspace"
aria-label="Link href"
/>
</GrFormField>
<GrFormField label="Accessibility label">
<GrInput
v-model="ariaLabel"
placeholder="Used by screen readers when needed"
aria-label="Link accessibility label"
/>
</GrFormField>
<GrFormField label="Target">
<GrRadioGroup v-model="targetMode" :options="targetOptions" variant="button" size="sm" />
<GrInput
v-if="targetMode === 'custom'"
v-model="customTarget"
class="mt-3"
placeholder="workspace-frame"
aria-label="Custom target"
/>
</GrFormField>
<GrFormField label="Rel">
<GrSelect v-model="relMode" :options="relOptions" aria-label="Link rel" />
<GrInput
v-if="relMode === 'custom'"
v-model="customRel"
class="mt-3"
placeholder="author noopener"
aria-label="Custom rel"
/>
</GrFormField>
</div>
<div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
<GrSwitch v-model="external" size="sm">
External
</GrSwitch>
<GrSwitch v-model="disabled" size="sm">
Disabled
</GrSwitch>
</div>
</div>
</div>
</template>Variants and underline modes
<script setup lang="ts">
import { GrLink, type GrLinkTone } from '@feugene/granularity'
const tones: GrLinkTone[] = ['primary', 'neutral', 'success', 'warning', 'danger', 'info', 'slate', 'azure']
</script>
<template>
<div class="grid gap-5 text-sm">
<div class="grid gap-2">
<div class="text-xs font-600 uppercase tracking-wide text-[var(--gr-muted-fg)]">
variant="default" — colored by tone
</div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<GrLink v-for="tone in tones" :key="tone" href="#" :tone="tone" size="md">
{{ tone }}
</GrLink>
</div>
</div>
<div class="grid gap-2">
<div class="text-xs font-600 uppercase tracking-wide text-[var(--gr-muted-fg)]">
variant="muted" — subdued, tone appears on hover
</div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<GrLink v-for="tone in tones" :key="tone" href="#" :tone="tone" variant="muted" underline="always" size="md">
{{ tone }}
</GrLink>
</div>
</div>
</div>
</template>External
<script setup lang="ts">
import { GrLink } from '@feugene/granularity'
</script>
<template>
<div class="grid gap-3 text-sm">
<GrLink href="https://example.com/docs/showcase" external size="md">
Open external documentation
</GrLink>
<!-- Условие — фактическое поведение ссылки, а не проп `external`. -->
<GrLink href="https://example.com/changelog" target="_blank" size="md">
Changelog в новой вкладке
</GrLink>
<GrLink href="https://example.com/rss" external :external-icon="false" size="md">
Без иконки, но с предупреждением для скринридера
</GrLink>
<div class="rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-4 text-[var(--gr-muted-fg)]">
Ссылка, открывающаяся в новой вкладке, сама получает иконку, безопасный `rel` и скрытую
подсказку «откроется в новой вкладке» — предупреждение о смене контекста (WCAG 3.2.5).
</div>
</div>
</template>Disabled and muted states
Disabled mode рендерится как неинтерактивный текстовый элемент и сохраняет тот же layout внутри forms, cards и inline toolbars.
<script setup lang="ts">
import { GrCard, GrLink } from '@feugene/granularity'
</script>
<template>
<GrCard class="grid gap-3 p-4 text-sm">
<div class="flex flex-wrap items-center gap-3">
<GrLink href="#" size="md">
Ready link
</GrLink>
<GrLink href="#" disabled size="md">
Disabled link
</GrLink>
<GrLink href="#" underline="none" variant="muted" size="md">
Muted helper link
</GrLink>
</div>
<p class="text-[var(--gr-muted-fg)]">
Disabled mode рендерится как неинтерактивный текстовый элемент и сохраняет тот же layout внутри forms, cards и inline toolbars.
</p>
</GrCard>
</template>