Skip to content

feat(react-icons): expose the bundled icon variant as a data attribute - #1228

Open
Ray Knight (ArrayKnight) wants to merge 3 commits into
microsoft:mainfrom
ArrayKnight:headless-icon-variant
Open

feat(react-icons): expose the bundled icon variant as a data attribute#1228
Ray Knight (ArrayKnight) wants to merge 3 commits into
microsoft:mainfrom
ArrayKnight:headless-icon-variant

Conversation

@ArrayKnight

@ArrayKnight Ray Knight (ArrayKnight) commented Aug 29, 2026

Copy link
Copy Markdown

bundleIcon renders both glyphs of a pair and hides one with data-fui-icon-hidden. A consumer
stylesheet that wants to style the two variants differently — or select the visible one — currently
has to key off the fui-Icon-filled / fui-Icon-regular class names, which are a rendering detail
rather than a contract.

This adds data-fui-icon-variant="filled" | "regular" to the glyphs bundleIcon renders, exported
as DATA_FUI_ICON_VARIANT alongside the existing DATA_FUI_ICON, DATA_FUI_ICON_RTL,
DATA_FUI_ICON_HIDDEN and DATA_FUI_ICON_FONT. It is additive: no existing attribute, class name
or export changes, and nothing renders differently.

The headless entrypoint's bundled-variant test coverage is extended to assert the attribute on both
glyphs in both filled states, and both copies of the attribute documentation
(docs/headless.md and the docsite mirror) are updated.

Context. This comes out of building a complete Tailwind v4 + CSS Modules styling layer over
@fluentui/react-headless-components-preview in microsoft/fluentui — see microsoft/fluentui#36656. That
layer styles icons entirely from CSS, and the class-name route was the one place it had to depend on
a string rather than a contract. It is useful independently of that work: any consumer styling
headless icons hits the same wall.

fix(react-icons): dedupe CJS rewrite targets so concurrent rewrites cannot tear a file

(separate commit, unrelated to the above — happy to split into its own PR if preferred)

finalizeCjs collected rewrite targets into an array. A stale pre-renamed file and its fresh source
can resolve to the same target path, so the same file was rewritten twice concurrently and torn.
Collecting into a Set makes the target list unique.

docs(react-icons): explain how the hidden-variant rule is meant to be layered

(comment-only commit; drop it if you would rather not carry it)

[data-fui-icon-hidden] is the one rule in headless/styles.css consumers routinely override — any
hover or selection glyph swap reveals the inactive variant. Every other non-obvious rule in that
file carries a rationale block; this one did not, so the constraint it places on a consumer (import
the stylesheet below your component layers, then a normal declaration wins) was only discoverable by
experiment. The declaration itself is untouched — it matches upstream/main and published npm
(2.0.339) byte for byte.


Why a data-* attribute rather than the class names

Selecting on fui-Icon-filled couples a consumer's stylesheet to a string the package is free to
change. The DATA_FUI_ICON_* family is the surface the headless entrypoint already treats as its
public selector contract — data-fui-icon, data-fui-icon-rtl, data-fui-icon-hidden,
data-fui-icon-font. The variant is the one gap in that family, and it is the fact a CSS-first
consumer reaches for most, because a bundleIcon pair is precisely the case where "which glyph is
this?" has to be answerable from CSS alone.

On the name. The attribute is namespaced to match every sibling constant:
data-fui-icon-variant / DATA_FUI_ICON_VARIANT. A bare data-variant on every bundled glyph
would collide with consumer and design-system attributes, so the short spelling was ruled out before
this PR rather than argued for in it.

What changed, file by file

feat commitpackages/react-icons/src/headless/{shared.ts,index.ts} and bundleIcon. Adds
export const DATA_FUI_ICON_VARIANT = 'data-fui-icon-variant', re-exports it, and spreads
{ [DATA_FUI_ICON_VARIANT]: 'filled' } / 'regular' onto the two glyphs bundleIcon renders.

Test. headless.test.tsx gains
bundleIcon stamps data-fui-icon-variant on both glyphs regardless of filled, asserting both glyphs
in both filled states. The pre-existing "bundleIcon renders both variants with data attributes"
test (which asserts only DATA_FUI_ICON_HIDDEN) is left as-is. Suite: 24 passed, was 23.

Docs. Both copies updated — packages/react-icons/docs/headless.md and its mirror
packages/docsite/stories/Icons/Headless/IconsHeadless.md. Each gains the constant in the export
list and a bundleIcon variant row in the "How it works" table naming the fui-Icon-filled /
fui-Icon-regular class names as the Griffel-side equivalent. Prettier reflowed the table's second
column as a result; that column-width churn is the only incidental diff in either file.

One caveat for the reviewer, pre-existing and left alone. The docsite mirror tells readers to
import these constants from @fluentui/react-icons/headless/utils, but src/headless/utils.ts
re-exports only the class-name constants, never the DATA_FUI_ICON_* ones — they come from the
./headless barrel. That inaccuracy predates this change; worth fixing separately.

fix commitpackages/react-icons/scripts/module-format.js, in finalizeCjs: toRewrite
changes from a string[] with .push() to a Set<string> with .add(), spread at the
forEachConcurrent call site. Genuinely independent of everything else here — it has nothing to do
with Tailwind, styling, or the attribute above.

Relationship to the companion microsoft/fluentui PR

The dependency is real and narrow. The styling layer imports @fluentui/react-icons/headless in
106 files, but that entrypoint is not what it needs this PR for — the whole src/headless/
tree and its ./headless* export conditions already exist here.

What it needs is the data-fui-icon-variant stamp specifically. It publishes two @custom-variant
definitions against the attribute in a documented public export of its own package, and every
component that swaps a glyph — Button, ToggleButton, MenuButton, MenuItem,
BreadcrumbButton, Tab — reaches the attribute only through those two variants. Without this
change, those rules match nothing, silently.

So the linkage is: this PR opens first; the companion PR keeps a pinned resolution until this one
merges and publishes, then moves to the published version and drops it. Cross-links in both
directions.

Changelog. No change file: this repo uses neither beachball nor changesets. It runs
@commitlint/config-conventional with nx release driven by conventional commits, and
packages/react-icons/CHANGELOG.md is machine-generated with PR backlinks. All three commit
subjects are valid conventional commits.

`bundleIcon` renders both glyphs of a pair and hides one with `data-fui-icon-hidden`.
A consumer stylesheet that wants to style the two variants differently — or select
the visible one — currently has to key off the `fui-Icon-filled` / `fui-Icon-regular`
class names, which are a rendering detail rather than a contract.

This stamps `data-fui-icon-variant="filled" | "regular"` on the glyphs `bundleIcon`
renders, exported as `DATA_FUI_ICON_VARIANT` alongside the existing `DATA_FUI_ICON`,
`DATA_FUI_ICON_RTL`, `DATA_FUI_ICON_HIDDEN` and `DATA_FUI_ICON_FONT`. It is additive:
no existing attribute, class name or export changes, and nothing renders differently.

The headless entrypoint's test for bundled-variant data attributes is extended to
cover it, and both copies of the attribute documentation (`docs/headless.md` and the
docsite mirror) are updated.
… layered

`[data-fui-icon-hidden]` is the one rule in this file consumers routinely have to
override — a hover or selection glyph swap reveals the inactive variant. Every other
non-obvious rule here already carries a rationale block; this one did not, so the
constraint it places on consumers (import this file BELOW your component layers, then
a normal declaration wins) was only discoverable by experiment.

Comment only; the declaration is unchanged.
…annot tear a file

`finalizeCjs` collected rewrite targets into an array. A stale pre-renamed file and
its fresh source can resolve to the same target path, so the same file was queued
twice and `forEachConcurrent` rewrote it from two workers at once, tearing it.
Collecting into a `Set` makes the target list unique.

Unrelated to the variant attribute in the preceding commits; happy to split this into
its own PR if preferred.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant