GrLink

Package: @feugene/granularitycoreGroup: actions

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 ownas replaces the root tag with RouterLink or NuxtLink without a wrapper;
  • the link leads outsideexternal adds a sign and an “in a new tab” label for a screen reader;
  • the link lives inside textunderline and tone keep it recognisable inside a paragraph.

When to take something else

NeedTake
The action changes state rather than the addressGrButton
The link looks like a buttonGrButton with as/href
A list of transitionsGrSidebar / GrDropdownMenu
The path to the current pageGrBreadcrumbs

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

ConditionWhat 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…

Code
<GrLink />

Install

npm i @feugene/granularity

Import

import { GrLink } from '@feugene/granularity/components/GrLink'

API

Props

PropTypedefaultDescription
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined"primary"The semantic colour of the link from the `GrTone` palette.
variantGrLinkVariant | undefined"default"The level of accent: `default` (coloured) or `muted` (dimmed, the accent on hover).
disabledboolean | undefinedfalse
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
asstring | Component | undefinedundefinedA 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`.
hrefstring | undefinedundefined
targetstring | undefinedundefined
relstring | undefinedundefined
externalboolean | undefinedfalse
underlineGrLinkUnderline | undefined"auto"
externalIconboolean | undefinedundefinedThe external-link icon. By default it is shown on any link that opens in a new tab — not only with `external`.
newTabLabelstring | undefinedundefinedi18n: the hidden hint "opens in a new tab".

Slots

SlotTypeDescription
defaultanyThe text of the link.

Examples 4

Interactive link constructor

Builderdepends on the showcase environment
<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('&', '&amp;').replaceAll('"', '&quot;')
}

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

variant="default" — colored by tone
variant="muted" — subdued, tone appears on hover

Variants
<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

Open external documentation (opens in a new tab) Changelog в новой вкладке (opens in a new tab) Без иконки, но с предупреждением для скринридера (opens in a new tab)
Ссылка, открывающаяся в новой вкладке, сама получает иконку, безопасный `rel` и скрытую подсказку «откроется в новой вкладке» — предупреждение о смене контекста (WCAG 3.2.5).

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.

Disabled States
<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>

Component documentationAll components