Skip to content
HELiX

CSS Parts API

apps/docs/src/content/docs/components/shadow-dom/parts Click to copy
Copied! apps/docs/src/content/docs/components/shadow-dom/parts

CSS Shadow Parts provide a standardized, powerful mechanism for styling elements inside a shadow DOM from outside the component. While CSS custom properties control values (colors, spacing), parts expose specific elements for direct styling—giving consumers precise control over component appearance while maintaining encapsulation boundaries.

This guide explores the CSS Parts API in depth, covering the part attribute, the ::part() pseudo-element, part forwarding with exportparts, design patterns, and theming strategies proven in enterprise healthcare systems.

Before diving into CSS Parts, ensure you understand:

  • Basic CSS selector syntax and specificity
  • Web component fundamentals (custom elements, templates)

Shadow DOM encapsulation prevents external CSS from penetrating component internals—a critical feature for isolation. But this creates a challenge: how do consumers customize component appearance beyond what CSS custom properties allow?

CSS Shadow Parts solve this by letting component authors explicitly expose internal elements for styling. These exposed elements become “parts” that consumers can target from outside the shadow boundary using the ::part() pseudo-element.

Consider a button component with shadow DOM:

<hx-button>
#shadow-root (mode: open)
<button class="button">
<slot></slot>
</button>
</hx-button>

Without parts, consumers cannot style the internal <button> element. Global CSS cannot penetrate the shadow boundary:

/* ❌ This does NOT work — selector cannot cross shadow boundary */
hx-button button {
border-radius: 0;
}

With parts, the component author exposes the button:

hx-button.ts
render() {
return html`
<button part="button" class="button">
<slot></slot>
</button>
`;
}

Now consumers can style it:

/* ✅ This works — ::part() crosses the shadow boundary */
hx-button::part(button) {
border-radius: 0;
}

Parts establish a styling contract between component authors and consumers:

  • Authors decide which elements are styleable by adding part attributes
  • Consumers style those parts via ::part() selectors
  • Browser enforces the boundary—only exposed parts are accessible

This is opt-in theming. Unlike global CSS chaos, parts provide controlled, intentional styling access.

Component authors define parts by adding the global part attribute to elements in their shadow DOM templates.

hx-text-input.ts
override render() {
return html`
<div part="field" class="field">
<label part="label" class="field__label">
${this.label}
</label>
<div part="input-wrapper" class="field__input-wrapper">
<input part="input" class="field__input" type="text" />
</div>
<div part="help-text" class="field__help-text">
${this.helpText}
</div>
<div part="error" class="field__error">
${this.error}
</div>
</div>
`;
}

Each part attribute exposes that element as a styleable part. The part name (e.g., "input", "label") becomes the identifier consumers use with ::part().

A single element can expose multiple part names, separated by spaces:

render() {
return html`
<div part="alert message-container" class="alert">
<div part="icon alert-icon" class="alert__icon">
<slot name="icon"></slot>
</div>
<div part="message content" class="alert__message">
<slot></slot>
</div>
</div>
`;
}

Consumer can target using any name:

/* All three selectors target the same element */
hx-alert::part(alert) {
padding: 1rem;
}
hx-alert::part(message-container) {
padding: 1rem;
}
/* Either name targets the icon */
hx-alert::part(icon) {
color: blue;
}
hx-alert::part(alert-icon) {
color: blue;
}

When to use multiple names: Provide both generic (message) and specific (alert-icon) names for flexibility. Use when an element serves multiple conceptual roles.

Follow these conventions for maintainability:

ConventionExampleRationale
Lowercase, hyphenatedpart="input-wrapper"Consistent with HTML attribute style
Semantic, not structuralpart="message" not part="div-3"Survives refactoring
Scoped when neededpart="card-header"Prevents ambiguity in complex components
Avoid generic namespart="container" is vagueUse part="alert-container" instead

Anti-pattern: part="wrapper-1", part="elem-2" — meaningless names break on refactor.

Best practice: part="input", part="close-button", part="error-message" — semantic names communicate intent.

Styling Parts: The ::part() Pseudo-Element

Section titled “Styling Parts: The ::part() Pseudo-Element”

Consumers use the ::part() CSS pseudo-element to style exposed parts from outside the shadow DOM.

/* Target the button inside hx-button's shadow DOM */
hx-button::part(button) {
border-radius: 0;
text-transform: uppercase;
font-weight: 700;
}
/* Target the input inside hx-text-input's shadow DOM */
hx-text-input::part(input) {
border: 2px solid #d1d5db;
padding: 0.75rem 1rem;
font-size: 1rem;
}
/* Target the card container */
hx-card::part(card) {
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}

The ::part() selector gives you access to nearly all CSS properties:

  • Layout: display, position, flex, grid, float
  • Box model: width, height, padding, margin, border, border-radius
  • Typography: font-family, font-size, font-weight, line-height, letter-spacing, text-transform
  • Visual: color, background, box-shadow, opacity, filter
  • Transforms & transitions: transform, transition, animation
  • Interactions: cursor, pointer-events, user-select

Exception: You cannot modify semantic attributes via CSS (e.g., role, aria-*). These remain JavaScript/HTML-only.

Combine ::part() with standard pseudo-classes:

/* Hover state */
hx-button::part(button):hover {
background: #1e40af;
transform: translateY(-1px);
}
/* Focus state */
hx-text-input::part(input):focus {
border-color: var(--hx-color-primary-500);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
outline: none;
}
/* Disabled state */
hx-button::part(button):disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Checked state (for checkboxes, radios) */
hx-checkbox::part(control):checked {
background: var(--hx-color-primary-500);
border-color: var(--hx-color-primary-500);
}

Supported pseudo-classes: :hover, :focus, :active, :disabled, :checked, :invalid, :valid, :first-child, :last-child, :nth-child(), and more.

Unsupported pseudo-classes: Structural pseudo-classes that match based on tree information outside the shadow root (like :empty or :last-child where siblings are in light DOM) may not work as expected. Test thoroughly.

Target parts on specific component states:

/* Style primary button variant */
hx-button[variant='primary']::part(button) {
background: var(--hx-color-primary-500);
color: white;
}
/* Style error state inputs */
hx-text-input[error]::part(input) {
border-color: var(--hx-color-error-500);
}
/* Style disabled cards */
hx-card[disabled]::part(card) {
opacity: 0.6;
pointer-events: none;
}

Pattern: Combine host attribute selectors with part selectors for state-based styling.

The ::part() selector has intentional restrictions to preserve encapsulation:

You cannot traverse into parts:

/* ❌ These do NOT work */
hx-card::part(body) .some-class {
}
hx-card::part(body) > p {
}
hx-text-input::part(input-wrapper) input {
}

Why? Allowing descendant selection would expose the entire internal DOM structure, defeating encapsulation.

Workaround: If you need to style descendants, the component author must expose them as separate parts:

// Expose both wrapper AND input as parts
render() {
return html`
<div part="input-wrapper">
<input part="input" />
</div>
`;
}

You cannot target pseudo-elements on parts:

/* ❌ This does NOT work */
hx-button::part(button)::before {
content: '';
}
/* ❌ This does NOT work either */
hx-text-input::part(label)::after {
content: ' *';
}

Why? ::part() is itself a pseudo-element. CSS doesn’t allow chaining pseudo-elements.

Workaround: Component authors must expose wrapper elements if consumers need to style pseudo-elements:

render() {
return html`
<div part="label-wrapper" class="label-wrapper">
<label part="label">${this.label}</label>
</div>
`;
}
/* ✅ Now this works */
hx-text-input::part(label-wrapper)::after {
content: ' *';
color: red;
}

Without exportparts, parts are only visible to the immediate parent DOM. Parts in nested shadow roots are hidden (see next section).

When components nest other components, parts from child components are not visible to the outer page by default. The exportparts attribute solves this by explicitly forwarding parts up the component tree.

Consider a card component that contains a button:

<!-- Page (Light DOM) -->
<hx-card>
<!-- hx-card shadow root -->
#shadow-root
<div part="card">
<hx-button>
<!-- hx-button shadow root (nested) -->
#shadow-root
<button part="button">Click</button>
</hx-button>
</div>
</hx-card>

Without exportparts:

/* ✅ This works — hx-card exposes its own part */
hx-card::part(card) {
padding: 2rem;
}
/* ❌ This does NOT work — hx-button's part is hidden */
hx-card::part(button) {
background: blue;
}

The part="button" inside hx-button is encapsulated within its own shadow root. The page cannot see it through hx-card.

The hx-card component can forward the button’s parts using exportparts:

hx-card.ts
override render() {
return html`
<div part="card" class="card">
<div part="body" class="card__body">
<slot></slot>
</div>
<div part="actions" class="card__actions">
<hx-button exportparts="button">
<slot name="action"></slot>
</hx-button>
</div>
</div>
`;
}

Now the page can style it:

/* ✅ Button part is now accessible through hx-card */
hx-card::part(button) {
background: blue;
text-transform: uppercase;
}

Rename parts while forwarding to avoid naming collisions or provide clearer semantics:

<!-- Forward "button" as "action-button" -->
<hx-button exportparts="button: action-button"></hx-button>

Consumer styling:

/* Target the renamed part */
hx-card::part(action-button) {
background: green;
padding: 1rem 2rem;
}

Use case: When a component contains multiple buttons, rename them to indicate their roles (save-button, cancel-button, close-button).

Forward multiple parts with commas:

<hx-text-input exportparts="input, label, error, help-text"></hx-text-input>

With renaming:

<hx-text-input
exportparts="
input: field-input,
label: field-label,
error: field-error,
help-text: field-help
"
></hx-text-input>

Forward all parts from a child component with a prefix to prevent naming conflicts:

<!-- Forward all parts from hx-text-input with "field-" prefix -->
<hx-text-input exportparts="*: field-*"></hx-text-input>

Result:

/* Original part names in hx-text-input */
hx-text-input::part(input) {
}
hx-text-input::part(label) {
}
hx-text-input::part(error) {
}
/* After forwarding with prefix from hx-form */
hx-form::part(field-input) {
}
hx-form::part(field-label) {
}
hx-form::part(field-error) {
}

Best practice: Always use prefixed forwarding when composing components. This prevents part name collisions and maintains clear ownership.

Note: The wildcard syntax exportparts="*: *" is invalid and will not work. Always include a prefix when using wildcards.

Pattern 1: Structural Parts (Layout Control)

Section titled “Pattern 1: Structural Parts (Layout Control)”

Expose major layout containers to allow consumers to control component structure, spacing, and flow.

Example: Card Component

// hx-card.ts (simplified)
override render() {
return html`
<div part="card" class="card">
<div part="image" class="card__image" ?hidden=${!this._hasSlotContent.image}>
<slot name="image"></slot>
</div>
<div part="heading" class="card__heading" ?hidden=${!this._hasSlotContent.heading}>
<slot name="heading"></slot>
</div>
<div part="body" class="card__body">
<slot></slot>
</div>
<div part="footer" class="card__footer" ?hidden=${!this._hasSlotContent.footer}>
<slot name="footer"></slot>
</div>
<div part="actions" class="card__actions" ?hidden=${!this._hasSlotContent.actions}>
<slot name="actions"></slot>
</div>
</div>
`;
}

Consumer use case: Create horizontal card layouts using CSS Grid.

/* Horizontal card layout */
.feature-cards hx-card::part(card) {
display: grid;
grid-template-columns: 300px 1fr;
grid-template-areas:
'image heading'
'image body'
'image actions';
gap: 1.5rem;
}
.feature-cards hx-card::part(image) {
grid-area: image;
}
.feature-cards hx-card::part(heading) {
grid-area: heading;
}
.feature-cards hx-card::part(body) {
grid-area: body;
}
.feature-cards hx-card::part(actions) {
grid-area: actions;
}

Pattern 2: Interactive Parts (State Styling)

Section titled “Pattern 2: Interactive Parts (State Styling)”

Expose interactive elements (buttons, inputs, links) for state-specific styling (hover, focus, active, disabled).

Example: Button Component

hx-button.ts
override render() {
return html`
<button
part="button"
class="button"
?disabled=${this.disabled}
>
<slot></slot>
</button>
`;
}

Consumer use case: Apply custom focus indicators that meet or exceed the WCAG 2.2 AAA focus visibility criteria (2.4.7 / 2.4.11 / 2.4.13).

/* Custom focus ring with increased contrast */
hx-button::part(button):focus {
outline: 3px solid #ff6b00;
outline-offset: 3px;
box-shadow: 0 0 0 6px rgba(255, 107, 0, 0.2);
}
/* Custom disabled state for healthcare context */
hx-button::part(button):disabled {
opacity: 0.4;
cursor: not-allowed;
background: #e5e7eb;
color: #9ca3af;
}
/* Hover state for ghost buttons */
hx-button[variant='ghost']::part(button):hover {
background: rgba(37, 99, 235, 0.1);
border-color: var(--hx-color-primary-500);
}

Pattern 3: Form Field Parts (Comprehensive Theming)

Section titled “Pattern 3: Form Field Parts (Comprehensive Theming)”

Expose all major elements of form components for full design system integration.

Example: Text Input Component

The hx-text-input component exposes six parts for complete theming control:

// hx-text-input.ts (actual implementation)
override render() {
return html`
<div part="field" class="field">
<label part="label" class="field__label" for=${this._inputId}>
${this.label}
</label>
<div part="input-wrapper" class="field__input-wrapper">
<span class="field__prefix">
<slot name="prefix"></slot>
</span>
<input
part="input"
class="field__input"
id=${this._inputId}
type=${this.type}
.value=${live(this.value)}
/>
<span class="field__suffix">
<slot name="suffix"></slot>
</span>
</div>
<div part="help-text" class="field__help-text">${this.helpText}</div>
<div part="error" class="field__error">${this.error}</div>
</div>
`;
}

Consumer use case: Apply enterprise design system overrides.

/* Label styling for healthcare brand */
hx-text-input::part(label) {
font-family: var(--hx-font-family-sans);
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
/* Input styling with increased touch targets */
hx-text-input::part(input) {
border: 2px solid #d1d5db;
border-radius: 6px;
padding: 0.875rem 1rem; /* Larger for accessibility */
font-size: 1rem;
min-height: 44px; /* WCAG 2.2 SC 2.5.5 (Target Size — Enhanced, AAA): ≥44×44 CSS px */
transition:
border-color 0.2s,
box-shadow 0.2s;
}
/* Focus state with visible outline */
hx-text-input::part(input):focus {
border-color: var(--hx-color-primary-500);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
outline: 2px solid transparent; /* Ensures Windows High Contrast Mode shows outline */
}
/* Error state styling */
hx-text-input[error]::part(input) {
border-color: var(--hx-color-error-500);
background: var(--hx-color-error-50);
}
hx-text-input::part(error) {
color: var(--hx-color-error-700);
font-size: 0.875rem;
font-weight: 500;
margin-top: 0.5rem;
}

Theming Strategy: Parts vs Custom Properties

Section titled “Theming Strategy: Parts vs Custom Properties”

CSS Shadow Parts and CSS custom properties (variables) both enable theming, but serve fundamentally different purposes.

Purpose: Control values—colors, spacing, typography, timing, z-index.

Scope: Cascade through shadow DOM boundaries automatically (inherited properties).

Use case: Global theming, brand colors, spacing scales, typography systems.

Example:

/* Global theme overrides at :root level */
:root {
--hx-color-primary-500: #007878; /* Teal brand color */
--hx-space-4: 1.5rem; /* Increased spacing scale */
--hx-border-radius-md: 8px; /* Rounded corners */
--hx-font-family-sans: 'Inter', sans-serif;
}

Components consume these tokens internally:

/* Inside hx-button.styles.ts */
.button {
background: var(--hx-button-bg, var(--hx-color-primary-500));
padding: var(--hx-button-padding, var(--hx-space-4));
border-radius: var(--hx-button-border-radius, var(--hx-border-radius-md));
font-family: var(--hx-button-font-family, var(--hx-font-family-sans));
}

Purpose: Control elements—layout, structure, element-specific overrides.

Scope: Requires explicit exposure via part attribute; does not cascade.

Use case: Fine-grained overrides, per-instance customization, structural changes.

Example:

/* Specific component instance overrides */
.hero hx-button::part(button) {
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 1.5rem 3rem;
min-width: 200px;
}
/* Layout modification */
.sidebar hx-card::part(card) {
display: flex;
flex-direction: column;
height: 100%;
}
.sidebar hx-card::part(body) {
flex: 1; /* Body takes remaining space */
}
ScenarioSolutionRationale
Global brand colorsCSS custom propertiesCascades automatically, affects all components
Spacing/typography scalesCSS custom propertiesDesign system tokens should be global
Component-level defaultsCSS custom propertiesAffects all instances of that component type
Element-specific overridesCSS partsTargets specific internal elements
Layout modificationsCSS partsStructural changes require element access
State styling (hover, focus, active)CSS partsPseudo-classes need element target
Per-instance customizationCSS partsInstance-specific, not global

Use both in tandem for maximum flexibility and maintainability:

/* Layer 1: Global theme via custom properties */
:root {
--hx-color-primary-500: #007878;
--hx-color-primary-600: #006060;
--hx-space-4: 1rem;
--hx-border-radius-md: 6px;
}
/* Layer 2: Component-level overrides via custom properties */
.admin-panel {
--hx-button-bg: var(--hx-color-primary-600); /* Darker in admin */
--hx-card-padding: var(--hx-space-6); /* More padding */
}
/* Layer 3: Instance-specific structural changes via parts */
.admin-panel hx-button.cta::part(button) {
text-transform: uppercase; /* Structural change */
letter-spacing: 0.1em;
padding: 1.5rem 3rem;
border-left: 4px solid var(--hx-color-primary-500); /* Use theme color */
}
.admin-panel hx-card.dashboard::part(card) {
display: grid; /* Layout override */
grid-template-columns: 1fr 2fr;
}

Pattern: Use custom properties for values that apply broadly. Use parts for structural or instance-specific changes.

The CSS Shadow Parts API is supported in all modern browsers:

BrowserVersionRelease Date
Chrome73+March 2019
Edge79+January 2020
Safari13.1+March 2020
Firefox72+January 2020

Current coverage: 96%+ of global browser usage (February 2026).

No polyfill needed: CSS Parts are natively supported. Unsupported browsers (e.g., IE11) simply ignore ::part() rules—components still function, just without custom part styling.

Testing note: Always test in Firefox, as it has the strictest Shadow DOM implementation. If it works in Firefox, it works everywhere.

  1. Expose parts intentionally: Only expose elements consumers should style. Over-exposure creates maintenance burden and implicit dependencies.

  2. Use semantic naming: part="button" is better than part="wrapper-1". Part names are a contract—make them meaningful.

  3. Document parts in JSDoc: Use @csspart comments for Custom Elements Manifest generation.

    /**
    * @csspart button - The native button element.
    * @csspart icon - The icon container (if slotted).
    */
  4. Coordinate with CSS custom properties: Parts handle structure; variables handle values. Provide both for flexibility.

  5. Use exportparts sparingly: Only forward parts when composition requires it. Avoid blindly forwarding all parts from all children.

  6. Prefix when forwarding: Use exportparts="*: prefix-*" to prevent naming collisions in nested components.

  7. Version carefully: Removing a part is a breaking change. Adding a part is non-breaking. Document part stability in release notes.

  1. Prefer CSS custom properties for global theming: Override brand colors, spacing, and typography at the :root level for consistency.

  2. Use ::part() for targeted overrides: Style specific component instances or variants, not the entire design system.

  3. Avoid implementation assumptions: Parts are a contract, but internal structure may change. Style the part itself, not presumed descendants.

  4. Combine with attribute selectors: Use hx-button[variant="primary"]::part(button) to target specific component states.

  5. Test across component updates: Since parts can be added or removed, test your overrides after library updates.

  6. Respect encapsulation: If a part isn’t exposed, there’s likely a reason. Request new parts via issues rather than hacking around the shadow boundary.

CSS Shadow Parts provide the precision and control needed for enterprise component theming. They bridge the gap between encapsulation (necessary for isolation) and customization (necessary for diverse use cases).

Key takeaways:

  • Parts are opt-in styling hooks—only exposed elements are styleable
  • Use part attribute in shadow DOM to expose elements
  • Use ::part() pseudo-element from outside to style them
  • Use exportparts to forward parts through nested shadow roots
  • Combine parts (for structure) with custom properties (for values) for layered theming
  • Parts are a contract—document and version them carefully

For HELiX (hx-library), parts are essential for integrating components into diverse healthcare design systems. They enable the foundation (the library) to serve infinite variations (client implementations) without sacrificing encapsulation or maintainability.