react-shadow-scope
v1.0.9
Published
Brings Shadow DOM CSS encapsulation to React, along with a DSD-compatible template element.
Downloads
3,751
Maintainers
Readme
React Shadow Scope
<Scope stylesheet={styles}>
Traditional global CSS risks naming collisions, specificity conflicts, and unwanted style inheritance. Modern tools have been designed to solve these problems by using emulated encapsulation, but nothing can protect from inherited styles except for shadow DOM.
This package does not burden you with all the boilerplate around shadow DOM, nor force you to use web components. Did you know you can attach a shadow root to regular elements, like a <div>
? That's essentially what react-shadow-scope
does behind the curtain.
Note This package supports Tailwind in the shadow DOM via the
<Tailwind>
component. Using Tailwind globally risks naming collisions with other utility classes. This can be especially important for library authors.
As a rule of thumb, you should limit your global CSS to little or nothing. The native @scope
rule can get you pretty far, but it still doesn't protect from inherited styles. Shadow DOM encapsulation is the single best tool we have.
Table of Contents
Install
npm i react-shadow-scope
Usage
Scope
To create a new CSS scope, import the Scope
component from the package and just pass a string to the stylesheet
prop.
import { Scope } from 'react-shadow-scope';
const MyComponent = () => (
<>
{/* Scope gives you protection from inherited styles! */}
<style>{`#Demo h1 { color: blue; text-decoration: underline; }`}</style>
<div id="Demo">
<h1>This title is blue with underline</h1>
<Scope stylesheet={`h1 { color: red; }`}>
<h1>This title is red without underline</h1>
<Scope stylesheet={`h1 { font-style: italic; }`}>
<h1>This title is italicized without underline or color</h1>
</Scope>
</Scope>
</div>
</>
);
Warning
There is a known bug in React that triggers false hydration mismatch errors when using Next.js. If you're using Next.js, you may set declarative shadow DOM to
emulated
oroff
by passing theconfig
prop.You can use
<ShadowScopeConfigProvider>
to apply the config options to all child instances.<ShadowScopeConfigProvider config={{ dsd: 'emulated' }}>
...OR you can pass it directly to each
<Scope>
,<CustomElement>
, or<Tailwind>
. Each instance will override the provider's config.<Scope config={{ dsd: 'emulated' }}>
Setting
dsd
toemulated
will initially render (hidden) HTML by parsing slots in the light DOM, then enhance with the shadow DOM after hydration completes. Settingdsd
tooff
will disable server-side rendering altogether.
Custom Tag Names
By default, <Scope>
renders a <react-shadow-scope>
element, but doesn't define it in the custom element registry. The custom tag name just avoids cases where <div>
or <span>
would break HTML validation.
This can be overridden via the tag
prop, in case of conflicts or for better legibility in the dev tools.
<Scope tag="my-element">
The above will output: <my-element>
If you're using TypeScript, you will need to merge with the interface where these element types are declared.
import { CustomIntrinsicElement } from 'react-shadow-scope';
declare global {
namespace ReactShadowScope {
interface CustomElements {
'my-element': CustomIntrinsicElement;
}
}
}
Note
In some cases, HTML requires certain nesting rules to be valid. For example,
<ul>
may only contain<li>
tags as direct children. To work around this, you can either render all<li>
tags in one parent<Scope>
, or apply your own semantics withrole="list"
androle="listitem"
to your markup instead of using<ul>
and<li>
.
Composing Styles
It's normally a good idea to contain the complexity of your design. In other words, instead of designing the different use cases outside a component, design from the inside by describing the use case, like usedFor="page"
or importance="urgent"
. The goal should be to eliminate the need for consumers of your component to write any CSS at all.
However, sometimes it's necessary to compose styles from the parent scope. In such an event, you may add a class to the <Scope>
component and select that to style the outer element. Although React applies some unique rules to custom elements, you can just use className
as usual and we'll forward it to class
internally.
This is a gray area that has some indirect impact on the shadow DOM via the cascade. You can also selectively reach into the shadow DOM with shadow parts. It's important to be aware this breaks encapsulation, so it's generally not the recommended approach. Although it's sometimes necessary or beneficial, it often isn't, so be careful. It may require shifting your mental model a bit at first.
Normalize CSS
This package borrows from normalize.css to make style defaults more consistent across browsers. This feature is opted-in by default to hopefully save you some hassle, but you can opt-out any time by setting the normalize
prop to false.
<Scope stylesheet={styles} normalize={false}>
All normalized styles are contained inside a @layer
called normalize
, which gives them the lowest priority, making them easy to override.
Note
By default,
<Scope>
appliesdisplay: contents;
to avoid problems with layouts. (This preserves accessibility because it lacks semantics to interfere with anyway.) You may override this with:host { /* overrides */ }
.
Constructed Style Sheets
For best performance, you can create a new CSSStyleSheet
object and pass it to the stylesheet
prop.
react-shadow-scope
exports a hook (useCSS
) that returns a tagged template function that will take care of this for you. It will detect support for the feature and fallback to a string if necessary. When rendering on the server, the styles will render in a <style>
tag.
import { useCSS, Scope } from 'react-shadow-scope';
const MyComponent = () => {
const css = useCSS();
return (
<Scope stylesheet={css`h1 { color: red }`}>
<h1>title here</h1>
</Scope>
);
}
To ensure that only one stylesheet gets constructed even when you use a component multiple times, you can create a Symbol
outside the component function, then pass it to the useCSS
hook. This will uniquely identify a single reference for each instance of the component.
const key = Symbol();
const MyComponent = () => {
const css = useCSS(key);
...
}
Note
When using a key, you may not use the resulting
css
function multiple times, because the same reference is shared between each function call. This means the last result will override all previous results. If you need multiple stylesheets, consider callinguseCSS
multiple times with different keys.const key1 = Symbol(); const key2 = Symbol(); const MyComponent = () => { const css1 = useCSS(key1); const css2 = useCSS(key2); ... }
You can also import the css
function directly, but the useCSS
hook works well with HMR without sacrificing performance.
import { css } from 'react-shadow-scope';
const stylesheet = css`h1 { color: red }`;
To use multiple stylesheets, you can also use the stylesheets
prop (plural) and pass an array.
<Scope stylesheets={[theme, styles]}>
Remote Style Sheets
If you'd rather save static assets, or you depend on a third-party stylesheet, you can pass a (relative or absolute) URL to the href
prop.
<Scope href="/mystyles.css">
When rendering on the server, this will simply add a <link>
tag pointing to the given href.
When rendering on the client, this will fetch the file as text, and create a CSSStyleSheet
instance from it. If adoptedStyleSheets
are not supported, it will fall back on the <link>
tag. All stylesheets are cached by href, so they won't be fetched (or constructed) multiple times even if they were fetched by a different <Scope>
.
You can also link multiple stylesheets using the hrefs
(plural) prop.
<Scope hrefs={['/theme.css', '/mystyles.css']}>
When linking external stylesheets, server-rendered components will appear as expected on the first paint. Client rendered components, however, would have a FOUC issue if not for some extra care. While the styles are busy loading on the client, we apply :host { visibility: hidden; }
by default. These styles can be customized as well, and will only apply while the fetch promise is pending.
<Scope href="/mystyles.css" pendingStyles={css`
:host {
display: block;
opacity: 0.3;
}
`}>
Excluding Children From the Scope
Most of the time, you won't want the children to be rendered in the same CSS scope as the component. In such a case, you will want to use <slot>
tags and pass children to the slottedContent
prop.
<Scope stylesheet={styles} slottedContent={children}>
<slot></slot>
</Scope>
This is just an abstraction over shadow DOM, so anything you can do with shadow DOM, you can do with slottedContent
, including named slots and so on.
But at the point you're taking full advantage these additional features, you may be entering territory where it becomes more practical to just use the bare syntax of declarative shadow DOM... which you can also do with this package!
Declarative Shadow DOM
If you want to use declarative shadow DOM directly, without the <Scope>
component, you can use <Template>
together with <CustomElement>
. This adds support to React for the native <template>
element, with some added features.
import { useCSS, Template, CustomElement } from 'react-shadow-scope';
const MyComponent = () => {
const css = useCSS();
return (
<CustomElement tag="card-element">
{/* Note the declarative `adoptedStyleSheets` prop! */}
<Template
shadowrootmode="closed"
adoptedStyleSheets={[css`/* styles here */`]}
>
<h1>
<slot name="heading">(Untitled)</slot>
</h1>
<slot>(No content)</slot>
</Template>
<span slot="heading">Title Here</span>
<p>Inside Default Slot</p>
</CustomElement>
);
}
Tailwind
Tailwind support is already built-in so you don't have to roll your own solution. Just install and set up the Tailwind package as usual, and this package will encapsulate it in the shadow DOM.
<Tailwind slottedContent={children}>
<h1 className="text-slate-900 font-extrabold text-4xl">
Hello from the Shadow DOM!
</h1>
<slot></slot>
</Tailwind>
Note
Your output CSS file should be in the
/public
folder (or wherever your static assets are served from.) The expected filename istailwind.css
by default, but can be customized (see next section).Be sure to remove tailwind from the
<link>
tag in your HTML. You may want to add this in its place:<style> body { margin: 0; line-height: inherit; } </style>
Tailwind Props
href
- This is/tailwind.css
if omitted. This will be fetched once and cached.customStyles
- Pass a string orCSSStyleSheet
(thecss
tagged template function is recommended)pendingStyles
- Works the same aspendingStyles
on the<Scope>
component.slottedContent
- Works the same asslottedContent
on the<Scope>
component.
Maintainers
License
MIT © 2023 Jonathan DeWitt