Icons

The generated icon components in @v4/tokens/icons, how to use one in a component or slot, and the directional opt-in for RTL.

2026-11-07

The icon system

The library ships 129 generated icon components (icon-*.tsx) in packages/tokens/src/icons/, exported from the @v4/tokens/icons subpath. They are the v4 successor to token-builder 1.x's fetchFigmaSVGs + SVGR pipeline, generated by the @v4/token-parser CLI — no SVGR dependency — from three Figma files: Ghost – Core 2.0 (67J5FrDZn5HzYzf5hJgoNA), ELB – Web – Core (HIjezWNeRQSBGkxx8VqmjA) and ELB – Web – Content (9iz5GeR9syO68cmPr1ppE1).

What a generated icon looks like

Each icon is a small React function component: an inline <svg> on a 16px grid, painted with fill="currentColor", marked aria-hidden="true" and focusable="false". They take an optional size (px) and className:

// packages/tokens/src/icons/icon-arrow-right.tsx — generated, do not edit
export function IconArrowRight({ size = 16, className }: IconProps = {}): ReactNode {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      focusable="false"
      className={className}
    >
      <path d="M12 4L10.59 5.41L16.17 11H4V13H16.17L10.59 18.59L12 20L20 12L12 4Z" />
    </svg>
  );
}
// the shared prop type — packages/tokens/src/icons/icon.types.ts
export interface IconProps {
  /** Rendered square size in px (defaults to the 16px icon grid). */
  size?: number;
  className?: string;
}

Because they paint with currentColor, an icon takes the text colour of its context — so a colour-set surface or a color on a wrapper flows straight into the glyph. You do not pass a colour prop.

Using an icon in a component or slot

Import the named component from @v4/tokens/icons and render it. It carries its own colour from currentColor, so the call site controls colour through the surrounding text colour:

import { IconChevronRight } from "@v4/tokens/icons";

<span className={styles.iconGlyph}>
  <IconChevronRight />
</span>;

The barrel exports every icon with a Icon-prefixed PascalCase name (IconArrowRight, IconChevronDown, IconCompetitionEuroleague, …), matching its icon-<kebab>.tsx file. The set includes core UI glyphs (arrows, chevrons, close, filter, download, bookmark), media controls, social-media marks (with -plain variants from the ELB files), the three competition marks (IconCompetitionEuroleague, IconCompetitionEurocup, IconCompetitionNextgen), and the IconFootball* glyphs that come with Ghost's icon set, which nothing on this site uses.

Icons are real components — when a Figma slot shows an icon, compose the real one; never hand-roll inline SVG demo shapes for it. Storybook slot demos and mocks must import the real icon from @v4/tokens/icons too.

How icons are generated and synced

The icons are generated from Figma by the token parser's icons command, wired as the sync-icons script in @v4/tokens:

# packages/tokens/package.json — six chained invocations (abridged)
v4-tokens icons -k 67J5FrDZn5HzYzf5hJgoNA -o src/icons                     # Ghost – Core 2.0: every icon
v4-tokens icons -k HIjezWNeRQSBGkxx8VqmjA -o src/icons                     # ELB – Web – Core: every icon
v4-tokens icons -k HIjezWNeRQSBGkxx8VqmjA -o src/icons --nodes "…"         # the six social "-Plain" marks
v4-tokens icons -k 9iz5GeR9syO68cmPr1ppE1 -o src/icons --nodes "…"         # Feature, Share-Plain
v4-tokens icons -k 67J5FrDZn5HzYzf5hJgoNA -o src/icons --nodes "…"         # Chevron-Down, Chevron-Up
v4-tokens icons -k 9iz5GeR9syO68cmPr1ppE1 -o src/icons --nodes "…"         # the three competition marks

Later invocations write into the same directory, so an ELB glyph replaces the Ghost glyph of the same name. --nodes takes Name@nodeId pairs for icons that live inside a component instance rather than on the icons page.

Run it with:

pnpm --filter @v4/tokens sync-icons   # requires FIGMA_PAT in the environment

Each invocation discovers the icon components in its Figma file (or takes the named --nodes), exports each SVG, and regenerates both the icon-<kebab>.tsx components and the index.ts barrel. The name mapping is mechanical: Figma Icon-Core/Arrow right → IconArrowRight / icon-arrow-right. Every generated file carries a // Generated by `v4-tokens icons` from Figma "<node name>" — do not edit by hand. banner: to add, remove or change an icon, change it in Figma and re-sync — never hand-edit the output.

The underlying CLI (v4-tokens icons -k <figma-file-key> -o <out-dir> [--page "- Icons"] [--filter <substr>] [--nodes "<Name@nodeId>,…"]) is documented in @v4/token-parser; the sync-icons script pins the file keys, nodes and output directory so a refresh is a single command.

Directional icons

The library uses CSS logical properties throughout, so layout, spacing and scroll logic all mirror for free under dir="rtl". Glyphs do not. An arrow or chevron drawn pointing right keeps pointing right on an RTL page, so a "next" paddle, a "back" button or a drill-in arrow would point the LTR way. directionalIcon is the one house mechanism that fixes that.

The mechanism

directionalIcon is a vanilla-extract class exported from @v4/ghost-core. The whole rule is a single reflection, keyed on the element's own resolved directionality:

// packages/ghost-core/src/utils/directional-icon.css.ts — the whole rule
export const directionalIcon = style({
  selectors: { "&:dir(rtl)": { transform: "scaleX(-1)" } },
});

:dir(rtl) is deliberate rather than an [dir="rtl"] & ancestor selector: it reads the element's own resolved direction, so a dir="ltr" island nested inside an RTL page correctly does not flip. It behaves identically in Chromium, Firefox and WebKit, on both HTML elements and <svg>.

Applying it

Apply the class at the call site, on the element that is (or wraps) the glyph, together with data-directional="true":

import { IconChevronLeft, IconChevronRight } from "@v4/tokens/icons";
import { directionalIcon } from "../../utils/directional-icon.css"; // in-package
// consumers outside the package: import { directionalIcon } from "@v4/ghost-core";

<span className={classnames(styles.iconGlyph, directionalIcon)} data-directional="true">
  {direction === "next" ? <IconChevronRight /> : <IconChevronLeft />}
</span>;

data-directional="true" makes the decision visible in the DOM for inspection and assertions — the mirroring is a declared decision made at the call site, following the lgRootWidth / lgOrientation precedent, not something inferred from the icon or the element type.

When to use it — and when not

The test is: does this glyph MEAN inline-start / inline-end? — not "does it look asymmetric?".

  • Mirror — only glyphs whose meaning IS an inline-axis direction: IconArrowLeft / IconArrowRight / IconChevronLeft / IconChevronRight used to express navigation (next/back paddles, drill-in arrows, pagination).
  • Do not mirror — block-axis glyphs (IconArrowUp, IconChevronDown — back-to-top, disclosure chevrons) and every semantic glyph (logos, play triangles, ticks, search glass). Flipping them is meaningless or actively wrong.

There is deliberately no blanket [dir="rtl"] svg { transform: scaleX(-1) }, and there must never be one — it would flip logos, play triangles, ticks and search glasses. The opt-in is per icon slot.

Rules and gotchas

  • Put it on a symmetric box. Flipping a wrapper also flips its padding, radius and border, so apply it to the glyph itself or to a wrapper that is symmetric on the inline axis (a centred square icon chip is safe).
  • Do not stack it on an element that already has a transform (e.g. a chevron rotated 180° when a disclosure is closed) — the later declaration wins outright.
  • Physical-by-design exceptions must be documented and tested. image-with-overlay's gradient is deliberately physical because its underlying Token Studio value is a physical 270° ramp; it carries a comment plus an e2e asserting it does not flip.

Guard rails

Two tests keep the mechanism honest: packages/ghost-core/src/utils/directional-icon.test.tsx asserts the declaration is present or absent (jsdom has no layout engine), and apps/e2e/tests/ghost-core/directional-icon.e2e.ts asserts the computed matrix(-1, 0, 0, 1, 0, 0) at dir="rtl" and none at dir="ltr" across every browser project.

A note on RTL in Ghost 2.0

Ghost 2.0 has no RTL artboards — every component set is authored LTR-only. The token layer anticipates RTL (a complete P.RTL.* typography ramp and fontFamilies.rtl ship in tokens.json), but mirroring behaviour is an engineering decision made against the WAI/W3C i18n guidance on directional icons, not a design hand-off. Revisit when design ships RTL artboards.