Technology Aug 31, 2026 · 16 min read

Building Fluentic Style: Styling DOM You Do Not Own With `createSheet`

This is part of my Building Fluentic Style series, where I’m writing down the design decisions, tradeoffs, and small surprises from building Fluentic Style. Most Fluentic examples start with a component that owns its DOM. <button css={styles.root}> <span css={styles.icon}>{icon}&...

DE
DEV Community
by OmniDev
Building Fluentic Style: Styling DOM You Do Not Own With `createSheet`

This is part of my Building Fluentic Style series, where I’m writing down the design decisions, tradeoffs, and small surprises from building Fluentic Style.

Most Fluentic examples start with a component that owns its DOM.

<button css={styles.root}>
  <span css={styles.icon}>{icon}</span>
  <span css={styles.label}>{children}</span>
</button>

That is the clean case.

The component renders the elements.

The component owns the styles.

The css prop attaches directly to the element that needs the style.

But not every UI is made of DOM you own.

Sometimes the interesting markup belongs to a third-party library.

A select library owns the control.

A date picker owns the calendar cells.

A rich text editor owns the content DOM.

A table library owns row and cell wrappers.

A menu might render its panel in a portal.

In those cases, the problem changes.

The question is no longer:

How do I style the element I am rendering?

The question becomes:

How do I style meaningful DOM inside a component I do not control?

That is the place where createSheet(...) became useful.

The Normal Fluentic Flow

When you own the DOM, Fluentic can stay very direct.

import { style } from '@fluentic/style';

const buttonStyles = {
  root: style({
    display: 'inline-flex',
    alignItems: 'center',
    gap: 8,
    borderRadius: 8,
    padding: '8px 12px',
  }),

  label: style({
    fontWeight: 650,
  }),
};

export function Button(props: { children: React.ReactNode }) {
  return (
    <button css={buttonStyles.root}>
      <span css={buttonStyles.label}>{props.children}</span>
    </button>
  );
}

The style value goes exactly where it belongs.

For a reusable component, the parts that outside code may style can become slots:

const buttonStyles = {
  root: style.slot({
    display: 'inline-flex',
    alignItems: 'center',
    gap: 8,
  }),

  label: style.slot({
    fontWeight: 650,
  }),
};

Then a theme can provide changes to those slots:

const dangerButton = style.scope([
  buttonStyles.root({
    backgroundColor: '#dc2626',
    color: 'white',
  }),

  buttonStyles.label({
    fontWeight: 800,
  }),
]);

That works because the component owns the styling targets.

The component decides which parts are public.

The caller styles those parts through Fluentic values, not through private DOM structure.

But third-party components are different.

Third-Party DOM Has A Different Boundary

Imagine wrapping React Select.

Your wrapper might look like this:

export function Select() {
  return (
    <div>
      <ReactSelect classNamePrefix="react-select" unstyled />
    </div>
  );
}

React Select renders internal DOM with classes like:

.react-select__control
.react-select__single-value
.react-select__menu
.react-select__option

Those are real styling targets, but they are not elements your wrapper renders directly.

You cannot write this:

<div css={styles.control} />

because you do not render the control element.

You may only own the boundary around it.

That is the gap createSheet(...) is meant to fill.

Naming Third-Party Parts

createSheet(...) is Fluentic’s way to attach a group of selector-based styles to one boundary element.

Instead of styling an element directly, the sheet says:

When this generated class is attached here, apply these Fluentic styles to these known selectors inside it.

So the first step is to name the third-party DOM parts you want to style:

import { createTokens, style } from '@fluentic/style';
import { createSheet } from '@fluentic/style/css';

const selectTokens = createTokens({
  accent: '#2563eb',
  panel: '#eff6ff',
  text: '#1e3a8a',
});

const selectPart = {
  control: style.selector('.react-select__control'),
  value: style.selector('.react-select__single-value'),
  menu: style.selector('.react-select__menu'),
  option: style.selector('.react-select__option'),
};

The selector strings still come from the third-party library.

That part is unavoidable.

But the important thing is where they live.

Instead of scattering .react-select__control through CSS files, nested selectors, wrapper components, and one-off overrides, the wrapper gathers the library’s public selector contract in one place.

After that, the styles can look like normal Fluentic style data.

const selectSheet = createSheet([
  selectPart.control({
    minHeight: 42,
    border: '1px solid',
    borderColor: selectTokens.accent,
    borderRadius: 10,
    backgroundColor: selectTokens.panel,
    boxShadow: 'none',
  }).hover({
    borderColor: '#1d4ed8',
  }),

  selectPart.value({
    color: selectTokens.text,
    fontWeight: 650,
  }),
]);

There is also a shorter form when the selector part has a default style immediately.

style.selector(...) can receive the style object directly, similar to style(...) or style.slot(...):

const selectSheet = createSheet([
  style.selector('.react-select__control', {
    minHeight: 42,
    border: '1px solid',
    borderColor: selectTokens.accent,
    borderRadius: 10,
    backgroundColor: selectTokens.panel,
    boxShadow: 'none',
  }).hover({
    borderColor: '#1d4ed8',
  }),

  style.selector('.react-select__single-value', {
    color: selectTokens.text,
    fontWeight: 650,
  }),
]);

I usually like naming selector parts when the wrapper is going to grow.

It gives the third-party selector contract a home.

But for a small wrapper, or for a quick one-off sheet, the direct form is convenient.

Attaching The Sheet To A Wrapper

A sheet is still an attachable Fluentic style value.

You can pass it directly to the css prop. The element that receives the sheet becomes the boundary for the selector rules.

export function Select() {
  return (
    <div css={selectSheet}>
      <ReactSelect classNamePrefix="react-select" unstyled />
    </div>
  );
}

In this version, the wrapper <div> receives Fluentic’s generated sheet class.

The selector rules are emitted under that class, so they only apply to matching third-party DOM inside this wrapper:

:where(.sheetClass) .react-select__control {
  min-height: 42px;
}

So this is not a loose global rule.

The sheet is attached to one boundary element, and its selectors are scoped from there.

Sometimes you do not want an extra wrapper element.

That can work too, as long as the third-party component lets you pass a class to the element that should become the sheet boundary.

React Select exposes a className prop for its root element, so the sheet can be resolved and passed directly:

import { getClassName } from '@fluentic/style';

export function Select() {
  const { className } = getClassName(selectSheet);

  return (
    <ReactSelect
      className={className}
      classNamePrefix="react-select"
      unstyled
    />
  );
}

Now the root element rendered by React Select becomes the boundary.

The same selector rules still apply. The only thing that changed is where Fluentic’s generated class lands.

With the wrapper version, it lands on your <div>.

With the direct version, it lands on the root element rendered by React Select.

For third-party APIs that accept normal className and style props, pass the resolved result through together:

import { getClassName } from '@fluentic/style';

export function FieldBridge() {
  const fieldProps = getClassName(fieldSheet);

  return (
    <ThirdPartyField
      {...fieldProps}
    />
  );
}

That is useful because getClassName(...) may return both a className and a style object. The style object carries token variables or dynamic inline values when the resolved style needs them.

If you need to merge Fluentic’s resolved props with existing props, make that explicit:

import {
  getClassName,
  mergeClassName,
  mergeStyle,
  type StyleProp,
} from '@fluentic/style';

export function FieldBridge(props: {
  css?: StyleProp;
  className?: string;
  style?: React.CSSProperties;
}) {
  const fieldProps = getClassName(fieldSheet);

  return (
    <ThirdPartyField
      className={mergeClassName([fieldProps.className, props.className])}
      style={mergeStyle([fieldProps.style, props.style])}
    />
  );
}

The direct path is best when the third-party component exposes the right root className or element props.

The wrapper path is best when you want a boundary element you fully own.

Why This Is Not Just Global CSS Again

Selector sheets still use selectors.

That is the point.

When the DOM belongs to another library, selectors may be the right contract.

But the selector does not become the whole styling approach.

The selector only says:

This is the third-party part I want to style.

The declarations are still Fluentic style data.

That means they can still use:

tokens
selector chains
priority rules
runtime resolution
CSS extraction
debug names
sourcemaps

The selector is the bridge.

It connects Fluentic styling to DOM that Fluentic did not render.

Why Not Just Use className?

Sometimes className is enough.

The direct getClassName(...) example above is exactly that case: the library gives you a boundary element, and you attach the generated sheet class there.

But selector sheets solve a different problem than a normal element class.

They are for cases where the library exposes fixed internal selectors, but not direct control over every internal element.

That is common with selects, editors, calendars, tables, menus, and other rich UI primitives.

The wrapper has a stable outside boundary.

The library has stable internal class names or data attributes.

createSheet(...) connects those two without turning every override into separate CSS.

Once the selectors are named, the rest of the styling path stays Fluentic:

tokens -> selector parts -> sheets -> combineStyle(...) -> css prop

That matters when the wrapper grows.

A small border override can become compact mode, danger tone, brand theme, disabled state, portal menu styles, and consumer overrides.

With selector sheets, those pieces remain composable style data.

Sheets Can Compose

A third-party wrapper usually starts small.

Then it gets a compact mode.

const compactSheet = createSheet([
  selectPart.control({
    minHeight: 34,
    borderRadius: 6,
  }),

  selectPart.value({
    fontSize: 13,
  }),
]);

Now the wrapper can compose the base sheet with the compact sheet:

import { combineStyle, type StyleProp, type StyleTheme } from '@fluentic/style';

export function Select(props: {
  compact?: boolean;
  css?: StyleProp;
  theme?: StyleTheme;
}) {
  const css = combineStyle(
    selectSheet,
    props.compact && compactSheet,
    props.theme,
  );

  return (
    <div css={[css, props.css]}>
      <ReactSelect classNamePrefix="react-select" unstyled />
    </div>
  );
}

Use combineStyle(...) to resolve the sheet with themes, variants, and token overrides.

Then append incoming props.css at the final attachment point with an array.

The same composition can work with the direct path when the third-party component accepts the resolved props:

export function FieldBridge(props: {
  compact?: boolean;
  css?: StyleProp;
  theme?: StyleTheme;
}) {
  const css = combineStyle(
    fieldSheet,
    props.compact && compactFieldSheet,
    props.theme,
  );

  const fieldProps = getClassName([css, props.css]);

  return (
    <ThirdPartyField
      {...fieldProps}
    />
  );
}

This is the same composition idea as normal Fluentic styles.

Base first.

Variant next.

Incoming theme after that.

Direct css last, at the final element boundary.

The difference is only the target shape.

Instead of styling elements directly, the sheet styles known selectors under the attached boundary.

Local Themes For Third-Party Components

Third-party wrappers often need local themes.

A select might need a blue tone, a rose tone, and a compact mode, but that does not always mean the entire app theme should know about React Select.

A selector sheet can contain token overrides too:

const blueSelect = createSheet([
  selectTokens.accent('#2563eb'),
  selectTokens.panel('#eff6ff'),
  selectTokens.text('#1e3a8a'),
]);

const roseSelect = createSheet([
  selectTokens.accent('#c0265a'),
  selectTokens.panel('#fce7ef'),
  selectTokens.text('#8f1238'),
]);

Then the wrapper can compose the selected tone with the structural sheet:

const css = combineStyle(
  selectSheet,
  props.tone === 'rose' ? roseSelect : blueSelect,
  props.compact && compactSheet,
);

And the incoming css prop still belongs at the final attachment point:

<div css={[css, props.css]} />

The control and value styles still read from selectTokens.

The token values can change locally, scoped to the wrapper instance.

That is the part I like here.

You do not have to introduce global CSS variables by hand.

You do not have to make the app theme aware of every third-party library.

You can keep the styling surface local to the wrapper.

Same Element Selectors

The basic selector form assumes the library class is somewhere below the attached sheet class:

style.selector('.react-select__control')

That produces a descendant selector:

:where(.sheetClass) .react-select__control { ... }

Sometimes the class you care about is on the attached element itself.

For that, the selector can use &:

const selectPart = {
  root: style.selector('&.react-select'),
};

That produces:

:where(.sheetClass).react-select { ... }

The difference is small but important:

.part   the part is below the attached class
&.part  the part is the attached element

This lets the sheet describe the DOM relationship instead of pretending all third-party markup has the same shape.

Portal Menus

Portals make the boundary more interesting.

A select control may render inside your wrapper, while the menu renders under document.body.

<div class="select-wrapper sheetClass">
  <div class="react-select__control">...</div>
</div>

<body>
  <div class="react-select__menu">...</div>
</body>

A wrapper sheet cannot reach across the document.

Once the menu moves outside the wrapper, this selector no longer matches:

:where(.sheetClass) .react-select__menu

because .react-select__menu is no longer below .sheetClass.

For portal cases, the sheet class needs to be attached to the portal target too.

Fluentic has createStyleTarget(...) for elements you do not render directly.

The React hook can stay small because createStyleTarget() owns the class and style bookkeeping:

import { createStyleTarget } from '@fluentic/style/css';
import { useEffect, useMemo } from 'react';

function useStyleTarget() {
  const target = useMemo(() => createStyleTarget(), []);

  useEffect(() => {
    return () => target.destroy();
  }, [target]);

  return target;
}

Then the wrapper can apply menu styles to the portal target:

import { combineStyle, type StyleProp, type StyleTheme } from '@fluentic/style';

export function Select(props: {
  compact?: boolean;
  css?: StyleProp;
  portalTarget: HTMLElement | null;
  theme?: StyleTheme;
}) {
  const target = useStyleTarget();

  const rootCss = combineStyle(
    [selectSheet, menuSheet],
    props.theme,
    props.compact && compactSheet,
  );

  const portalCss = combineStyle(
    menuSheet,
    props.theme,
  );

  target.apply(props.portalTarget, portalCss, {
    enabled: !!props.portalTarget,
  });

  return (
    <div css={[rootCss, props.css]}>
      <ReactSelect
        classNamePrefix="react-select"
        menuPortalTarget={props.portalTarget}
        unstyled
      />
    </div>
  );
}

Including menuSheet in rootCss keeps the non-portal case working.

Applying portalCss to the portal target covers the detached menu.

This is one of the cases where selector sheets feel less like a styling convenience and more like a DOM-boundary tool.

Optional Same-Or-Descendant Matching

Sometimes the same selector needs to work in both shapes.

The menu might be below the wrapper in one mode, but be the attached portal element in another.

For that, Fluentic supports &?.

const selectPart = {
  menu: style.selector('&?.react-select__menu'),
};

That means:

match the attached element if it has this class,
or match a descendant with this class

So these three forms have different meanings:

.part    descendant under the attached class
&.part   same element as the attached class
&?.part  either shape

That keeps portal-aware sheets from needing duplicate rules for the same component part.

Guardrails

Selector sheets are intentionally narrower than arbitrary global CSS.

Prefer one selector per style.selector(...) call:

const control = style.selector('.react-select__control');
const input = style.selector('.react-select__input-container');

If one logical part has multiple supported selectors, use an array:

const field = style.selector([
  '.react-select__control',
  '.react-select__input-container',
]);

Avoid comma-separated selector strings:

const field = style.selector('.one, .two');

Arrays keep the selector structure visible to Fluentic.

That matters for extraction, debugging, and keeping the wrapper’s selector contract readable.

There is another boundary too.

createSheet(...) expects selector items or token overrides.

If you are styling a normal element you own, use style(...).

If you are exposing component parts you own, use style.slot(...) and style.scope(...).

If the target is a selector under a boundary element, use createSheet(...).

What About Library CSS?

Some third-party libraries ship their own CSS.

Sometimes that CSS has high specificity.

Sometimes it loads after your app styles.

Fluentic can order the rules it owns, but it cannot move an external stylesheet into Fluentic’s ordering system.

For intentional interop overrides, Fluentic supports important values:

const selectSheet = createSheet([
  selectPart.control({
    borderColor: style.important(selectTokens.accent),
    boxShadow: style.important('none'),
  }),
]);

That emits the final declaration with !important.

I see this mostly as an interop tool.

Ordinary Fluentic styles usually should not need it, because composition, variants, scopes, and sheet order already describe the priority inside the styles Fluentic controls.

But third-party CSS is not always polite.

Sometimes a wrapper needs an explicit escape hatch.

Debugging Still Matters

Selector sheets still produce atomic rules.

If a later sheet sets the same selector property, the later class wins for that resolved style prop.

In debug mode, sheet selector classes use a shorter default format than ordinary atomic classes:

:where(.sheet-color-red--abc) .react-select__single-value {
  color: red;
}

The full selector is omitted by default because third-party selectors can get long.

If you want it while debugging, you can configure sheetClassNameFormat:

configureStyleRuntime({
  css: {
    debugClassName: true,
    sheetClassNameFormat: 'sheet-[(selector)-](property)[-(value)]--$hash',
  },
});

That is useful when you are tracing which selector sheet produced a rule.

The larger point is that selector sheets do not leave the Fluentic pipeline.

They still have generated classes.

They still have style data.

They can still participate in sourcemaps and debug traces.

The third-party selector does not become anonymous CSS pasted somewhere else.

When To Use createSheet

I think of createSheet(...) as the tool for DOM boundaries.

Use normal style(...) when you own the element.

Use style.slot(...) and style.scope(...) when you own a reusable component and want to expose public styling targets.

Use createSheet(...) when the target is a selector owned by something else:

third-party component internals
fixed library class names
data attributes from another component
portal or overlay DOM
wrapper components around non-Fluentic APIs
generated HTML from Markdown or CMS content

It is not meant to replace slots.

If you own the component, slots are usually the better contract. A slot is a TypeScript value that names a public component part directly.

But when the DOM is already owned by a library, a selector may be the only honest contract available.

createSheet(...) gives that contract a Fluentic shape.

Why This Became Its Own API

At first, I thought maybe normal styles plus getClassName(...) would be enough for most interop.

And for direct element interop, that is often true.

But third-party component wrappers kept showing a different pattern.

The wrapper owned one boundary element.

The library owned many internal elements.

The app wanted those internal elements to follow the same themes, variants, and debug path as the rest of the design system.

That is not really a className problem.

It is a selector boundary problem.

So createSheet(...) became the API for saying:

Attach this generated class here, and let these known selectors below it receive Fluentic styles.

That keeps the compromise small.

The selector is still there because the DOM is not yours.

But the rest of the styling flow stays connected.

Docs

Related docs:

Fluentic Style is still new and currently in beta.

I am still looking for early users and feedback, especially from people building real component systems where styling needs to cross more than one kind of boundary: owned DOM, reusable component slots, third-party selectors, and portal targets.

That is the reason createSheet(...) exists.

Not because Fluentic wants selectors to become the main component styling API.

Almost the opposite.

It exists for the places where selectors are the honest boundary, so the rest of the component styling approach does not have to fall apart there.

DE
Source

This article was originally published by DEV Community and written by OmniDev.

Read original article on DEV Community
Back to Discover

Reading List