Originally posted by ggdouglas April 17, 2026
Problem
The icon prop on several core components (Tab, Tree, Section, EntityTitle, NonIdealState, TagInput, Alert) accepts either a string icon name or a React element. When a string is passed, <Icon> renders a <span class="bp6-icon ..."> with all the classes the parent component expects (Classes.TAB_ICON, intent, etc.). When an element is passed, <Icon> returns it as-is, dropping every prop the parent tried to apply:
|
} else if (typeof icon !== "string") { |
|
return icon; |
|
} |
Result: element icons silently lose spacing, intent color, and other parent-provided styling. Button does not have this problem because its styles target .bp6-button .bp6-icon via descendant selector, so any element whose root is bp6-icon gets styled regardless of what the React side does.
Opening this for discussion before we commit to an approach. Five options are laid out below with pros and cons for each. Feedback on which direction to take (or on options I've missed) would be appreciated.
Option 1: Per-component wrapper <span> (current approach in PRs #8067 - #8073)
Each component that accepts an icon prop branches on type: string icons go through <Icon> as before, element icons are wrapped in a <span> with the relevant class.
// tabTitle.tsx
{icon != null &&
(typeof icon === "string" ? (
<Icon icon={icon} intent={intent} className={Classes.TAB_ICON} />
) : (
<span className={Classes.TAB_ICON}>{icon}</span>
))}
Pros
- Works regardless of whether the element forwards
className. The span applies the class externally.
- Self-contained in each component. No change to
<Icon>'s contract.
- No CSS refactor required.
Cons
- Adds a wrapper
<span> to the DOM. Consumers targeting selectors that assumed a flat structure (e.g. .bp6-tab-icon > svg) may break.
- Nested
bp6-icon inside the wrapper when the element is a @blueprintjs/icons component. Alert required a CSS fix (> .bp6-icon direct-child selector plus display: flex on the wrapper) to avoid double-applied margins.
- Same branching logic duplicated in every affected component.
- Inconsistent with Button's approach.
Option 2: Consumer adds the class themselves
No library change. Document that when passing a static icon element, the consumer is responsible for applying the relevant class (Classes.TAB_ICON, Classes.TREE_NODE_ICON, etc.).
import { Tab, Tabs, Classes } from "@blueprintjs/core";
import { Home } from "@blueprintjs/icons";
<Tabs>
<Tab id="home" icon={<Home className={Classes.TAB_ICON} />} title="Home" panel={...} />
</Tabs>
Pros
- Zero DOM or React changes. No regression risk for consumers targeting specific selectors.
Cons
- Silent footgun: forget the class and spacing is broken with no warning.
- Inconsistent internal API: string icons get the class automatically, element icons don't.
- Inconsistent with Button, which already handles element icons transparently.
- Requires migration docs across every affected component.
Option 3: Per-component cloneElement
Each component that accepts an icon prop uses React.cloneElement to inject the class onto the passed element's root.
// tabTitle.tsx
{icon != null &&
(typeof icon === "string" ? (
<Icon icon={icon} intent={intent} className={Classes.TAB_ICON} />
) : isValidElement<{ className?: string }>(icon) ? (
cloneElement(icon, {
className: classNames(Classes.TAB_ICON, icon.props.className),
})
) : (
icon
))}
Pros
- No wrapper span. DOM structure stays identical to today.
- Works transparently for any element whose root forwards
className (all @blueprintjs/icons components do).
- Consumer does not need to know about the class.
Cons
cloneElement silently no-ops for components that don't forward className.
- Same branching logic duplicated in every affected component.
- Intent propagation to element icons still requires additional plumbing per component.
Option 4: Move to Button's CSS pattern (descendant selectors)
Drop the dedicated spacing class in favor of CSS descendant selectors, matching the pattern Button already uses.
// before
.#{$ns}-tab-icon {
margin-right: ...;
}
// after
.#{$ns}-tab > .#{$ns}-icon,
.#{$ns}-tab-title > .#{$ns}-icon {
margin-right: ...;
}
React side stays exactly as it is on develop:
{icon != null && <Icon icon={icon} intent={intent} />}
The bp6-tab-icon class can stay on string icons as a stable hook for consumers that target it, but it's no longer required for spacing.
Pros
- Matches the CSS pattern Button already uses. Consistent across components.
- No DOM change, no React-side branching.
- Works transparently for any element whose root is
bp6-icon (all @blueprintjs/icons components, any future <IconNext>).
- No wrapper span means no new selector surface that consumers might accidentally target.
Cons
- CSS refactor across multiple components, with some risk of specificity collisions in consumer stylesheets.
- Does not propagate intent to element icons.
- Does not help consumers passing a custom element whose root is not
bp6-icon (though this is already the case for Button).
Option 5: <Icon> clones the element and injects className/intent
Change the pass-through branch in <Icon> to merge the parent-provided className and intent class onto the element's root via cloneElement.
// icon.tsx
} else if (typeof icon !== "string") {
if (isValidElement<{ className?: string }>(icon)) {
return cloneElement(icon, {
className: classNames(icon.props.className, className, Classes.intentClass(intent)),
});
}
return icon;
}
All existing callers stay exactly as they are. <Tab> already does <Icon icon={icon} className={Classes.TAB_ICON} intent={intent} /> for string icons and passes icon through for elements. With this change, the element case receives those props merged onto its root.
Pros
- One-line centralized fix. No per-component changes needed.
- Every existing
<Icon> call site is fixed automatically: Tab, Tree, Section, EntityTitle, NonIdealState, TagInput, Alert.
- No wrapper span, no DOM change.
- Consumers do not need to know about any specific class.
- Intent propagates to element icons (previously silently dropped).
- Consistent internal contract:
icon="home" and icon={<Home />} behave the same way.
Cons
cloneElement requires the passed element to forward className to its root. Works for all @blueprintjs/icons, silently no-ops for components that drop the prop.
- Subtle contract change to
<Icon> itself: previously a pure pass-through for elements, now merges className and intent. A consumer relying on the pass-through behavior would see a change.
- Does not inject size classes onto the element, since the element's root already has its own size classes and double-applying would be wrong. Parent-provided
size on <Icon> is ignored for element icons (same as today).
Discussed in #8077
Originally posted by ggdouglas April 17, 2026
Problem
The
iconprop on several core components (Tab,Tree,Section,EntityTitle,NonIdealState,TagInput,Alert) accepts either a string icon name or a React element. When a string is passed,<Icon>renders a<span class="bp6-icon ...">with all the classes the parent component expects (Classes.TAB_ICON, intent, etc.). When an element is passed,<Icon>returns it as-is, dropping every prop the parent tried to apply:blueprint/packages/core/src/components/icon/icon.tsx
Lines 159 to 161 in fb02745
Result: element icons silently lose spacing, intent color, and other parent-provided styling. Button does not have this problem because its styles target
.bp6-button .bp6-iconvia descendant selector, so any element whose root isbp6-icongets styled regardless of what the React side does.Opening this for discussion before we commit to an approach. Five options are laid out below with pros and cons for each. Feedback on which direction to take (or on options I've missed) would be appreciated.
Option 1: Per-component wrapper
<span>(current approach in PRs #8067 - #8073)Each component that accepts an
iconprop branches on type: string icons go through<Icon>as before, element icons are wrapped in a<span>with the relevant class.Pros
className. The span applies the class externally.<Icon>'s contract.Cons
<span>to the DOM. Consumers targeting selectors that assumed a flat structure (e.g..bp6-tab-icon > svg) may break.bp6-iconinside the wrapper when the element is a@blueprintjs/iconscomponent. Alert required a CSS fix (> .bp6-icondirect-child selector plusdisplay: flexon the wrapper) to avoid double-applied margins.Option 2: Consumer adds the class themselves
No library change. Document that when passing a static icon element, the consumer is responsible for applying the relevant class (
Classes.TAB_ICON,Classes.TREE_NODE_ICON, etc.).Pros
Cons
Option 3: Per-component
cloneElementEach component that accepts an
iconprop usesReact.cloneElementto inject the class onto the passed element's root.Pros
className(all@blueprintjs/iconscomponents do).Cons
cloneElementsilently no-ops for components that don't forwardclassName.Option 4: Move to Button's CSS pattern (descendant selectors)
Drop the dedicated spacing class in favor of CSS descendant selectors, matching the pattern Button already uses.
React side stays exactly as it is on
develop:The
bp6-tab-iconclass can stay on string icons as a stable hook for consumers that target it, but it's no longer required for spacing.Pros
bp6-icon(all@blueprintjs/iconscomponents, any future<IconNext>).Cons
bp6-icon(though this is already the case for Button).Option 5:
<Icon>clones the element and injects className/intentChange the pass-through branch in
<Icon>to merge the parent-providedclassNameand intent class onto the element's root viacloneElement.All existing callers stay exactly as they are.
<Tab>already does<Icon icon={icon} className={Classes.TAB_ICON} intent={intent} />for string icons and passesiconthrough for elements. With this change, the element case receives those props merged onto its root.Pros
<Icon>call site is fixed automatically: Tab, Tree, Section, EntityTitle, NonIdealState, TagInput, Alert.icon="home"andicon={<Home />}behave the same way.Cons
cloneElementrequires the passed element to forwardclassNameto its root. Works for all@blueprintjs/icons, silently no-ops for components that drop the prop.<Icon>itself: previously a pure pass-through for elements, now merges className and intent. A consumer relying on the pass-through behavior would see a change.sizeon<Icon>is ignored for element icons (same as today).