Repository: marigold-ui/marigold
Pull request #2146 (2022-06-07 09:27:01)

feat: forward ref for `<Checkbox>`
Ref #2145


Patch:
diff --git a/.changeset/khaki-pumpkins-burn.md b/.changeset/khaki-pumpkins-burn.md
new file mode 100644
index 000000000..d9665a012
--- /dev/null
+++ b/.changeset/khaki-pumpkins-burn.md
@@ -0,0 +1,7 @@
+---
+"@marigold/docs": minor
+"@marigold/components": minor
+"@marigold/system": minor
+---
+
+feat: Improve usage of Marigold when not used as a full page app
diff --git a/.changeset/short-wombats-relate.md b/.changeset/short-wombats-relate.md
new file mode 100644
index 000000000..3a9c5d9c2
--- /dev/null
+++ b/.changeset/short-wombats-relate.md
@@ -0,0 +1,5 @@
+---
+"@marigold/components": minor
+---
+
+feat: forward ref of `<Checkbox>`
diff --git a/docs/content/components/provider.mdx b/docs/content/components/provider.mdx
index 91982d30b..0530ad42d 100644
--- a/docs/content/components/provider.mdx
+++ b/docs/content/components/provider.mdx
@@ -12,6 +12,9 @@ import { MarigoldProvider } from '@marigold/components';
 
 ## Props
 
-| Property           | Type    | Default     |
-| :----------------- | :------ | :---------- |
-| `theme` (optional) | `Theme` | `undefined` |
+| Property              | Type        | Default | Description                                                                                                                                                                                                                                                           |
+| :-------------------- | :---------- | :------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `children`            | `ReactNode` |         | The children of the component.                                                                                                                                                                                                                                        |
+| `theme`               | `Theme`     |         | The use that should be used within the provider context.                                                                                                                                                                                                              |
+| `selector` (optional) | `string`    |         | By default normalization is applied globally. By specifying a selector you can scope normalization and other CSS from `theme.root` to a certain element in the DOM. This is useful if you want to run your app only on a portion of the page or are cascading themes. |
+| `normalizeDocument`   | `boolean`   | `true`  | Apply CSS normalization to the document (`html` and `body`).                                                                                                                                                                                                          |
diff --git a/packages/components/src/Checkbox/Checkbox.tsx b/packages/components/src/Checkbox/Checkbox.tsx
index 3bbf16173..7457e0241 100644
--- a/packages/components/src/Checkbox/Checkbox.tsx
+++ b/packages/components/src/Checkbox/Checkbox.tsx
@@ -1,7 +1,8 @@
-import React, { ReactNode } from 'react';
+import React, { forwardRef, ReactNode } from 'react';
 import { useCheckbox, useCheckboxGroupItem } from '@react-aria/checkbox';
 import { useFocusRing } from '@react-aria/focus';
 import { useHover } from '@react-aria/interactions';
+import { useObjectRef } from '@react-aria/utils';
 import { useToggleState } from '@react-stately/toggle';
 import { AriaCheckboxProps } from '@react-types/checkbox';
 
@@ -97,124 +98,132 @@ export interface CheckboxProps
 
 // Component
 // ---------------
-export const Checkbox = ({
-  size,
-  variant,
-  disabled,
-  checked,
-  defaultChecked,
-  indeterminate,
-  readOnly,
-  required,
-  error,
-  ...props
-}: CheckboxProps) => {
-  const ref = React.useRef<HTMLInputElement>(null);
-  // Adjust props to the react-aria API
-  const checkboxProps = {
-    isIndeterminate: indeterminate,
-    isDisabled: disabled,
-    isReadOnly: readOnly,
-    isRequired: required,
-    validationState: error ? 'invalid' : 'valid',
-  } as const;
+export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
+  (
+    {
+      size,
+      variant,
+      disabled,
+      checked,
+      defaultChecked,
+      indeterminate,
+      readOnly,
+      required,
+      error,
+      ...props
+    },
+    ref
+  ) => {
+    const inputRef = useObjectRef(ref);
+    // Adjust props to the react-aria API
+    const checkboxProps = {
+      isIndeterminate: indeterminate,
+      isDisabled: disabled,
+      isReadOnly: readOnly,
+      isRequired: required,
+      validationState: error ? 'invalid' : 'valid',
+    } as const;
 
-  /**
-   * Use hook depending if the checkbox is used inside a group or standalone.
-   * This is unusual, but since the checkboxs is not moving out of the group,
-   * it should be safe.
-   */
-  const groupState = useCheckboxGroupContext();
+    /**
+     * Use hook depending if the checkbox is used inside a group or standalone.
+     * This is unusual, but since the checkboxs is not moving out of the group,
+     * it should be safe.
+     */
+    const groupState = useCheckboxGroupContext();
 
-  /* eslint-disable react-hooks/rules-of-hooks */
-  const { inputProps } = groupState
-    ? useCheckboxGroupItem(
-        {
-          ...props,
-          ...checkboxProps,
-          /**
-           * value is optional for standalone checkboxes, but required when
-           * used inside a group.
-           */
-          value: props.value as string,
-        },
-        groupState,
-        ref
-      )
-    : useCheckbox(
-        {
-          isSelected: checked,
-          defaultSelected: defaultChecked,
-          ...checkboxProps,
-          ...props,
-        },
-        useToggleState({
-          isSelected: checked,
-          defaultSelected: defaultChecked,
-          ...props,
-        }),
-        ref
-      );
-  /* eslint-enable react-hooks/rules-of-hooks */
+    /* eslint-disable react-hooks/rules-of-hooks */
+    const { inputProps } = groupState
+      ? useCheckboxGroupItem(
+          {
+            ...props,
+            ...checkboxProps,
+            /**
+             * value is optional for standalone checkboxes, but required when
+             * used inside a group.
+             */
+            value: props.value as string,
+          },
+          groupState,
+          inputRef
+        )
+      : useCheckbox(
+          {
+            isSelected: checked,
+            defaultSelected: defaultChecked,
+            ...checkboxProps,
+            ...props,
+          },
+          useToggleState({
+            isSelected: checked,
+            defaultSelected: defaultChecked,
+            ...props,
+          }),
+          inputRef
+        );
+    /* eslint-enable react-hooks/rules-of-hooks */
 
-  const styles = useComponentStyles(
-    'Checkbox',
-    { variant: groupState?.variant || variant, size: groupState?.size || size },
-    { parts: ['container', 'label', 'checkbox'] }
-  );
+    const styles = useComponentStyles(
+      'Checkbox',
+      {
+        variant: groupState?.variant || variant,
+        size: groupState?.size || size,
+      },
+      { parts: ['container', 'label', 'checkbox'] }
+    );
 
-  const { hoverProps, isHovered } = useHover({});
-  const { isFocusVisible, focusProps } = useFocusRing();
-  const stateProps = useStateProps({
-    hover: isHovered,
-    focus: isFocusVisible,
-    checked: inputProps.checked,
-    disabled: inputProps.disabled,
-    error: groupState?.error || error,
-    readOnly,
-    indeterminate,
-  });
+    const { hoverProps, isHovered } = useHover({});
+    const { isFocusVisible, focusProps } = useFocusRing();
+    const stateProps = useStateProps({
+      hover: isHovered,
+      focus: isFocusVisible,
+      checked: inputProps.checked,
+      disabled: inputProps.disabled,
+      error: groupState?.error || error,
+      readOnly,
+      indeterminate,
+    });
 
-  return (
-    <Box
-      as="label"
-      __baseCSS={{
-        display: 'flex',
-        alignItems: 'center',
-        gap: '1ch',
-        position: 'relative',
-      }}
-      css={styles.container}
-      {...hoverProps}
-      {...stateProps}
-    >
+    return (
       <Box
-        as="input"
-        ref={ref}
-        css={{
-          position: 'absolute',
-          width: '100%',
-          height: '100%',
-          top: 0,
-          left: 0,
-          zIndex: 1,
-          opacity: 0.0001,
-          cursor: inputProps.disabled ? 'not-allowed' : 'pointer',
+        as="label"
+        __baseCSS={{
+          display: 'flex',
+          alignItems: 'center',
+          gap: '1ch',
+          position: 'relative',
         }}
-        {...inputProps}
-        {...focusProps}
-      />
-      <Icon
-        checked={inputProps.checked}
-        indeterminate={indeterminate}
-        css={styles.checkbox}
+        css={styles.container}
+        {...hoverProps}
         {...stateProps}
-      />
-      {props.children && (
-        <Box css={styles.label} {...stateProps}>
-          {props.children}
-        </Box>
-      )}
-    </Box>
-  );
-};
+      >
+        <Box
+          as="input"
+          ref={inputRef}
+          css={{
+            position: 'absolute',
+            width: '100%',
+            height: '100%',
+            top: 0,
+            left: 0,
+            zIndex: 1,
+            opacity: 0.0001,
+            cursor: inputProps.disabled ? 'not-allowed' : 'pointer',
+          }}
+          {...inputProps}
+          {...focusProps}
+        />
+        <Icon
+          checked={inputProps.checked}
+          indeterminate={indeterminate}
+          css={styles.checkbox}
+          {...stateProps}
+        />
+        {props.children && (
+          <Box css={styles.label} {...stateProps}>
+            {props.children}
+          </Box>
+        )}
+      </Box>
+    );
+  }
+);
diff --git a/packages/components/src/Provider/MarigoldProvider.tsx b/packages/components/src/Provider/MarigoldProvider.tsx
index a515992d4..717d399ab 100644
--- a/packages/components/src/Provider/MarigoldProvider.tsx
+++ b/packages/components/src/Provider/MarigoldProvider.tsx
@@ -1,47 +1,46 @@
 import React from 'react';
 import { OverlayProvider } from '@react-aria/overlays';
 import {
-  Theme,
   Global,
+  GlobalProps,
+  Theme,
   ThemeProvider,
   ThemeProviderProps,
   useTheme,
   __defaultTheme,
 } from '@marigold/system';
 
-// Theme Extension
-// ---------------
-export interface RootThemeExtension<Value> {
-  root?: {
-    body?: Value;
-    html?: Value;
-  };
-}
-
 // Props
 // ---------------
 export interface MarigoldProviderProps<T extends Theme>
-  extends ThemeProviderProps<T> {}
+  extends ThemeProviderProps<T>,
+    GlobalProps {}
 
 // Provider
 // ---------------
 export function MarigoldProvider<T extends Theme>({
-  theme,
   children,
+  theme,
+  selector,
+  normalizeDocument = true,
 }: MarigoldProviderProps<T>) {
   const outer = useTheme();
   const isTopLevel = outer.theme === __defaultTheme;
 
+  if (outer.theme?.root && !isTopLevel && !selector) {
+    throw new Error(
+      `[MarigoldProvider] You cannot nest a MarigoldProvider inside another MarigoldProvider without a "selector"! 
+      Nested themes with a "root" property must specify a "selector" to prevent accidentally overriding global CSS`
+    );
+  }
+
   return (
     <ThemeProvider theme={theme}>
-      {isTopLevel ? (
-        <>
-          <Global />
-          <OverlayProvider>{children}</OverlayProvider>
-        </>
-      ) : (
-        children
-      )}
+      <Global
+        normalizeDocument={isTopLevel && normalizeDocument}
+        selector={selector}
+      />
+      {isTopLevel ? <OverlayProvider>{children}</OverlayProvider> : children}
     </ThemeProvider>
   );
 }
diff --git a/packages/components/src/theme.ts b/packages/components/src/theme.ts
index 24fee963b..bf2557b01 100644
--- a/packages/components/src/theme.ts
+++ b/packages/components/src/theme.ts
@@ -1,8 +1,4 @@
-import type { CSSObject, Theme as Scales } from '@marigold/system';
-
-// Provider
-// ---------------
-import type { RootThemeExtension } from './Provider';
+import type { Theme as BaseTheme } from '@marigold/system';
 
 // Components
 // ---------------
@@ -70,6 +66,6 @@ interface ComponentStyles
     TooltipThemeExtension,
     UnderlayThemeExtension {}
 
-export interface Theme extends Scales, RootThemeExtension<CSSObject> {
+export interface Theme extends BaseTheme {
   components: ComponentStyles;
 }
diff --git a/packages/system/src/components/Box/Box.stories.tsx b/packages/system/src/components/Box/Box.stories.tsx
index 3c31d47b4..0776a26d3 100644
--- a/packages/system/src/components/Box/Box.stories.tsx
+++ b/packages/system/src/components/Box/Box.stories.tsx
@@ -3,7 +3,7 @@ import type { Meta, ComponentStory } from '@storybook/react';
 import { Box } from './Box';
 
 export default {
-  title: 'Components/Box',
+  title: 'System/Box',
   argTypes: {
     children: {
       control: {
diff --git a/packages/system/src/components/Box/Box.tsx b/packages/system/src/components/Box/Box.tsx
index da29a3228..e4ffd754e 100644
--- a/packages/system/src/components/Box/Box.tsx
+++ b/packages/system/src/components/Box/Box.tsx
@@ -8,9 +8,8 @@ import {
   PolymorphicComponentWithRef,
 } from '@marigold/types';
 
-import { getNormalizedStyles } from '../../normalize';
+import { transformPseudos } from './selector';
 import { CSSObject } from '../../types';
-import { transformPseudos } from './utils';
 
 export interface StyleProps
   extends Pick<
@@ -77,7 +76,6 @@ const createThemedStyle =
   ({ as, __baseCSS, styles, css }: CreateStyleProps) =>
   (theme: Theme) => {
     const themedStyles = merge.all([
-      getNormalizedStyles(as),
       transformStyleObject(__baseCSS)(theme),
       transformStyleObject(styles)(theme),
       transformStyleObject(css)(theme),
diff --git a/packages/system/src/components/Box/utils.ts b/packages/system/src/components/Box/selector.ts
similarity index 100%
rename from packages/system/src/components/Box/utils.ts
rename to packages/system/src/components/Box/selector.ts
diff --git a/packages/system/src/components/Global/Global.stories.tsx b/packages/system/src/components/Global/Global.stories.tsx
new file mode 100644
index 000000000..2bbdd0558
--- /dev/null
+++ b/packages/system/src/components/Global/Global.stories.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import type { Meta, ComponentStory } from '@storybook/react';
+import { Global } from './Global';
+import { ThemeProvider } from '../../hooks';
+
+export default {
+  title: 'System/Global',
+  argTypes: {
+    selector: {
+      control: {
+        type: 'text',
+      },
+      description: 'The CSS selector to apply to the styles (default document)',
+    },
+    normalizeDocument: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Normalize the document?',
+    },
+  },
+} as Meta;
+
+const theme = {
+  root: {
+    button: {
+      color: '#fff0f6',
+      border: '1px solid #a61e4d',
+      background: '#d6336c',
+    },
+  },
+};
+
+export const Basic: ComponentStory<typeof Global> = args => (
+  <ThemeProvider theme={theme}>
+    <Global {...args} />
+    <div>
+      <button>Button</button>
+    </div>
+  </ThemeProvider>
+);
+
+export const Custom: ComponentStory<typeof Global> = () => (
+  <ThemeProvider theme={theme}>
+    <Global selector="#custom" />
+    <p>
+      Element normalization and globals styles are only applied to the "Inside
+      Button"
+    </p>
+    <div style={{ paddingBottom: 10 }}>
+      <button>Outside Button</button>
+    </div>
+    <div id="custom">
+      <button>Inside Button</button>
+    </div>
+  </ThemeProvider>
+);
diff --git a/packages/system/src/components/Global/Global.tsx b/packages/system/src/components/Global/Global.tsx
index 07cb500e1..72643515e 100644
--- a/packages/system/src/components/Global/Global.tsx
+++ b/packages/system/src/components/Global/Global.tsx
@@ -1,39 +1,33 @@
 import React from 'react';
 import { Global as EmotionGlobal } from '@emotion/react';
-import { useTheme } from '../../hooks';
 
-/**
- * CSS snippet and idea from:
- * https://css-tricks.com/revisiting-prefers-reduced-motion-the-reduced-motion-media-query/
- */
-const reduceMotionStyles = {
-  '@media screen and (prefers-reduced-motion: reduce), (update: slow)': {
-    '*': {
-      animationDuration: '0.001ms !important',
-      animationIterationCount: '1 !important',
-      transitionDuration: '0.001ms !important',
-    },
-  },
+import { useTheme } from '../../hooks/useTheme';
+import * as normalize from './normalize';
+
+export type GlobalProps = {
+  /**
+   * CSS Selector to change the element that the styles will be applied to.
+   */
+  selector?: string;
+
+  /**
+   * Normalize `html` and `body`? Defaults to `true`.
+   */
+  normalizeDocument?: boolean;
 };
 
-export const Global = () => {
-  const { css } = useTheme();
-  const styles = css({
-    html: {
-      height: '100%',
-      /**
-       * Prevent Mobile Safari from zooming stuff ...
-       * Source: https://css-tricks.com/your-css-reset-needs-text-size-adjust-probably/
-       */
-      textSizeAdjust: 'none',
-      variant: 'root.html',
-    },
-    body: {
-      height: '100%',
-      lineHeight: 1.5,
-      WebkitFontSmoothing: 'antialiased',
-      variant: 'root.body',
-    },
-  });
-  return <EmotionGlobal styles={{ reduceMotionStyles, ...styles }} />;
+export const Global = ({ normalizeDocument = true, selector }: GlobalProps) => {
+  const { css, theme } = useTheme();
+  const rootStyles = css(theme?.root || {});
+
+  const styles = [
+    normalizeDocument ? normalize.document : {},
+    // Prefix normalization and root styles with selector if provided.
+    selector
+      ? { [`:where(${selector})`]: normalize.element }
+      : normalize.element,
+    selector ? { [`:where(${selector})`]: rootStyles } : rootStyles,
+  ];
+
+  return <EmotionGlobal styles={styles} />;
 };
diff --git a/packages/system/src/components/Global/normalize.ts b/packages/system/src/components/Global/normalize.ts
new file mode 100644
index 000000000..a0ad850c2
--- /dev/null
+++ b/packages/system/src/components/Global/normalize.ts
@@ -0,0 +1,97 @@
+/**
+ * Normalize styling of certain elements between browsers.
+ * Based on https://www.joshwcomeau.com/css/custom-css-reset/
+ */
+export const document = {
+  'html, body': {
+    height: '100%',
+  },
+
+  html: {
+    /**
+     * Prevent Mobile Safari from zooming stuff ...
+     * Source: https://css-tricks.com/your-css-reset-needs-text-size-adjust-probably/
+     */
+    textSizeAdjust: 'none',
+  },
+
+  body: {
+    lineHeight: 1.5,
+    WebkitFontSmoothing: 'antialiased',
+    /**
+     * We have to duplicate this here, since the "*" selector will not be
+     * applied to the body element if a custom `selector` is used.
+     */
+    margin: 0,
+  },
+
+  /**
+   * CSS snippet and idea from:
+   * https://css-tricks.com/revisiting-prefers-reduced-motion-the-reduced-motion-media-query/
+   */
+  '@media screen and (prefers-reduced-motion: reduce), (update: slow)': {
+    '*': {
+      animationDuration: '0.001ms !important',
+      animationIterationCount: '1 !important',
+      transitionDuration: '0.001ms !important',
+    },
+  },
+};
+
+export const element = {
+  '*, *::before, *::after': {
+    boxSizing: 'border-box',
+  },
+
+  '*': {
+    margin: 0,
+  },
+
+  a: {
+    textDecoration: 'none',
+  },
+
+  'p, h1, h2, h3, h4, h5, h6': {
+    overflowWrap: 'break-word',
+  },
+
+  'img, picture, video, canvas, svg': {
+    display: 'block',
+    maxWidth: '100%',
+  },
+
+  button: {
+    display: 'block',
+    appearance: 'none',
+    font: 'inherit',
+    background: 'transparent',
+    textAlign: 'center',
+  },
+
+  input: {
+    display: 'block',
+    appearance: 'none',
+    font: 'inherit',
+    '&::-ms-clear': {
+      display: 'none',
+    },
+    '&::-webkit-search-cancel-button': {
+      WebkitAppearance: 'none',
+    },
+  },
+
+  select: {
+    display: 'block',
+    appearance: 'none',
+    font: 'inherit',
+    '&::-ms-expand': {
+      display: 'none',
+    },
+  },
+
+  textarea: {
+    display: 'block',
+    appearance: 'none',
+    font: 'inherit',
+  },
+} as const;
diff --git a/packages/system/src/components/SVG/SVG.tsx b/packages/system/src/components/SVG/SVG.tsx
index 03dc08c12..06d7b4f05 100644
--- a/packages/system/src/components/SVG/SVG.tsx
+++ b/packages/system/src/components/SVG/SVG.tsx
@@ -1,11 +1,7 @@
 import { jsx } from '@emotion/react';
 import { ComponentProps } from '@marigold/types';
-
-import { getNormalizedStyles } from '../../normalize';
 import { useTheme } from '../../hooks';
 
-const normalizedStyles = getNormalizedStyles('svg');
-
 // Make sure that numbered values are converted to px.
 const toDimension = (value: number | string | number[] | string[]) =>
   Array.isArray(value)
@@ -31,7 +27,6 @@ export const SVG = ({
     {
       viewBox: '0 0 24 24',
       css: css({
-        ...normalizedStyles,
         fill,
         width: toDimension(props.width || size),
         height: toDimension(props.height || size),
diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts
index 711d26589..2103701b1 100644
--- a/packages/system/src/index.ts
+++ b/packages/system/src/index.ts
@@ -1,5 +1,3 @@
 export * from './components';
 export * from './hooks';
 export * from './types';
-
-export * from './normalize';
diff --git a/packages/system/src/normalize.ts b/packages/system/src/normalize.ts
deleted file mode 100644
index 54b6a1b43..000000000
--- a/packages/system/src/normalize.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * Normalize styling of certain elements between browsers.
- * Based on https://www.joshwcomeau.com/css/custom-css-reset/
- */
-import { ElementType } from 'react';
-
-const base = {
-  boxSizing: 'border-box',
-  margin: 0,
-  minWidth: 0,
-} as const;
-
-const a = {
-  ...base,
-  textDecoration: 'none',
-} as const;
-
-const text = {
-  ...base,
-  overflowWrap: 'break-word',
-} as const;
-
-const media = {
-  ...base,
-  display: 'block',
-  maxWidth: '100%',
-} as const;
-
-const button = {
-  ...base,
-  display: 'block',
-  appearance: 'none',
-  font: 'inherit',
-  background: 'transparent',
-  textAlign: 'center',
-} as const;
-
-const input = {
-  ...base,
-  display: 'block',
-  appearance: 'none',
-  font: 'inherit',
-  '&::-ms-clear': {
-    display: 'none',
-  },
-  '&::-webkit-search-cancel-button': {
-    WebkitAppearance: 'none',
-  },
-} as const;
-
-const select = {
-  ...base,
-  display: 'block',
-  appearance: 'none',
-  font: 'inherit',
-  '&::-ms-expand': {
-    display: 'none',
-  },
-} as const;
-
-const textarea = {
-  ...base,
-  display: 'block',
-  appearance: 'none',
-  font: 'inherit',
-} as const;
-
-// Normalize
-// ---------------
-export const normalize = {
-  base,
-  a,
-  p: text,
-  h1: text,
-  h2: text,
-  h3: text,
-  h4: text,
-  h5: text,
-  h6: text,
-  img: media,
-  picture: media,
-  video: media,
-  canvas: media,
-  svg: media,
-  select,
-  button,
-  textarea,
-  input,
-} as const;
-
-export type NormalizedElement = keyof typeof normalize;
-
-/**
- * Type-safe helper to get normalization. If no normalization is found,
- * returns the *base* normalization.
- */
-export const getNormalizedStyles = (val?: ElementType) =>
-  typeof val === 'string' && val in normalize
-    ? normalize[val as NormalizedElement] // Typescript doesn't infer this correctly
-    : normalize.base;
diff --git a/packages/system/src/types/theme.ts b/packages/system/src/types/theme.ts
index bc2c06ac2..ca425382d 100644
--- a/packages/system/src/types/theme.ts
+++ b/packages/system/src/types/theme.ts
@@ -112,6 +112,13 @@ export interface ZeroSizeScale<T> extends SizeScale<T> {
  * Base theme with typings for available scales properties.
  */
 export interface Theme {
+  /**
+   * Styles that applied to the root of the app, where the root can be either the document
+   * for full page apps or an element if you're only using Marigold on a portion of the page.
+   * This is useful for additional normalizationor and to style non-Marigold elements.
+   */
+  root?: CSSObject;
+
   /**
    * To configure the default breakpoints used in responsive array values,
    * add a breakpoints array to your theme.


Test patch:
diff --git a/packages/components/src/Checkbox/Checkbox.test.tsx b/packages/components/src/Checkbox/Checkbox.test.tsx
index 34e7c7d52..a7d59468d 100644
--- a/packages/components/src/Checkbox/Checkbox.test.tsx
+++ b/packages/components/src/Checkbox/Checkbox.test.tsx
@@ -235,3 +235,16 @@ test('controlled', () => {
   fireEvent.click(input);
   expect(onChange).toHaveBeenCalledWith(false);
 });
+
+test('forwards ref', () => {
+  const ref = React.createRef<HTMLInputElement>();
+  render(
+    <ThemeProvider theme={theme}>
+      <Checkbox data-testid="checkbox" ref={ref}>
+        Check it
+      </Checkbox>
+    </ThemeProvider>
+  );
+
+  expect(ref.current).toBeInstanceOf(HTMLInputElement);
+});
diff --git a/packages/components/src/Provider/MarigoldProvider.test.tsx b/packages/components/src/Provider/MarigoldProvider.test.tsx
index 9a1fb5cc0..5d85e9565 100644
--- a/packages/components/src/Provider/MarigoldProvider.test.tsx
+++ b/packages/components/src/Provider/MarigoldProvider.test.tsx
@@ -1,8 +1,8 @@
 /* eslint-disable testing-library/no-node-access */
 /* eslint-disable testing-library/no-container */
 import React from 'react';
-import { useTheme } from '@marigold/system';
 import { render, screen } from '@testing-library/react';
+import { useTheme } from '@marigold/system';
 
 import { MarigoldProvider } from './MarigoldProvider';
 
@@ -24,7 +24,17 @@ const theme = {
   },
 };
 
-test('themes can be cascaded', () => {
+let errorMock: jest.SpyInstance;
+
+beforeEach(() => {
+  errorMock = jest.spyOn(console, 'error').mockImplementation();
+});
+
+afterEach(() => {
+  errorMock.mockRestore();
+});
+
+test('support cascading themes', () => {
   const outerTheme = {
     colors: {
       primary: 'coral',
@@ -72,7 +82,7 @@ test('themes can be cascaded', () => {
   `);
 });
 
-test('OverlayProvider is added', () => {
+test('add OverlayProvider from `react-aria`', () => {
   const { container } = render(
     <MarigoldProvider theme={theme}>Test</MarigoldProvider>
   );
@@ -95,7 +105,7 @@ test('OverlayProvider is added only once', () => {
   ).toEqual(1);
 });
 
-test('applies global styles for body and html based on `theme.root`', () => {
+test('apply styles base on theme (`theme.root`)', () => {
   const theme = {
     fonts: {
       body: 'Inter',
@@ -137,3 +147,98 @@ test('applies global styles for body and html based on `theme.root`', () => {
   expect(body).toHaveStyle(`font-weight: ${theme.fontWeights.body}`);
   expect(body).toHaveStyle(`line-height: ${theme.lineHeights.body}`);
 });
+
+test('normalize document (html, body)', () => {
+  render(<MarigoldProvider theme={theme}>child</MarigoldProvider>);
+
+  const html = document.querySelector('html')!;
+  expect(html).toHaveStyle('height: 100%');
+
+  const body = document.querySelector('body')!;
+  expect(body).toHaveStyle('height: 100%');
+  expect(body).toHaveStyle('line-height: 1.5');
+});
+
+test('opt out of document normalization', () => {
+  render(
+    <MarigoldProvider theme={theme} normalizeDocument={false}>
+      child
+    </MarigoldProvider>
+  );
+
+  const html = document.querySelector('html')!;
+  expect(html).not.toHaveStyle('height: 100%');
+
+  const body = document.querySelector('body')!;
+  expect(body).not.toHaveStyle('height: 100%');
+  expect(body).not.toHaveStyle('line-height: 1.5');
+});
+
+test('cascading fails if inner theme has root styles', () => {
+  const outerTheme = {
+    root: {
+      background: 'coral',
+    },
+  };
+
+  const innerTheme = {
+    root: {
+      background: 'gainsboro',
+    },
+  };
+
+  expect(() =>
+    render(
+      <MarigoldProvider theme={outerTheme}>
+        <MarigoldProvider theme={innerTheme}>child</MarigoldProvider>
+      </MarigoldProvider>
+    )
+  ).toThrowError();
+  expect(errorMock).toHaveBeenCalled();
+});
+
+test('cascading without a selector is allowed when inner theme has not root styles', () => {
+  const outerTheme = {
+    colors: {
+      primary: 'coral',
+    },
+  };
+
+  const innerTheme = {
+    colors: {
+      primary: 'gainsboro',
+    },
+  };
+
+  expect(() =>
+    render(
+      <MarigoldProvider theme={outerTheme}>
+        <MarigoldProvider theme={innerTheme}>child</MarigoldProvider>
+      </MarigoldProvider>
+    )
+  ).not.toThrowError();
+});
+
+test('cascading with a selector is allowed when inner theme has specified a selector', () => {
+  const outerTheme = {
+    root: {
+      background: 'coral',
+    },
+  };
+
+  const innerTheme = {
+    root: {
+      background: 'gainsboro',
+    },
+  };
+
+  expect(() =>
+    render(
+      <MarigoldProvider theme={outerTheme}>
+        <MarigoldProvider theme={innerTheme} selector="#root">
+          <div id="root" />
+        </MarigoldProvider>
+      </MarigoldProvider>
+    )
+  ).not.toThrowError();
+});
diff --git a/packages/system/src/components/Box/Box.test.tsx b/packages/system/src/components/Box/Box.test.tsx
index a32801bc0..8a8eaed16 100644
--- a/packages/system/src/components/Box/Box.test.tsx
+++ b/packages/system/src/components/Box/Box.test.tsx
@@ -2,7 +2,6 @@ import React from 'react';
 import { render, screen } from '@testing-library/react';
 
 import { ThemeProvider } from '../../hooks';
-import { normalize } from '../../normalize';
 
 import { Box, StyleProps } from './Box';
 
@@ -112,35 +111,6 @@ test('forwards ref', () => {
   expect(ref.current instanceof HTMLButtonElement).toBeTruthy();
 });
 
-test('apply normalized styles', () => {
-  render(<Box>Test</Box>);
-  const element = screen.getByText('Test');
-  const { base } = normalize;
-
-  // Smoketest
-  expect(element).toHaveStyle(`box-sizing: ${base.boxSizing}`);
-  expect(element).toHaveStyle(`margin: ${base.margin}px`);
-  expect(element).toHaveStyle(`min-width: ${base.minWidth}`);
-});
-
-test('base normalization is always applied', () => {
-  render(<Box as="button">Test</Box>);
-  const element = screen.getByText('Test');
-  const { base } = normalize;
-
-  expect(element).toHaveStyle(`box-sizing: ${base.boxSizing}`);
-  expect(element).toHaveStyle(`margin: ${base.margin}px`);
-  expect(element).toHaveStyle(`min-width: ${base.minWidth}`);
-});
-
-test('apply normalized styles based on element', () => {
-  render(<Box as="h1">Test</Box>);
-  const element = screen.getByText('Test');
-  const { h1 } = normalize;
-
-  expect(element).toHaveStyle(`overflow-wrap: ${h1.overflowWrap}`);
-});
-
 test('accepts default styling via "__baseCSS" prop', () => {
   render(<Box __baseCSS={{ color: 'hotpink' }}>Test</Box>);
   const element = screen.getByText('Test');
@@ -148,7 +118,7 @@ test('accepts default styling via "__baseCSS" prop', () => {
   expect(element).toHaveStyle('color: hotpink');
 });
 
-test('default styling overrides normalization', () => {
+test('default styling overrides (global) normalization', () => {
   render(
     <ThemeProvider theme={theme}>
       <Box __baseCSS={{ m: 'medium' }}>Test</Box>
diff --git a/packages/system/src/components/Box/utils.test.ts b/packages/system/src/components/Box/selector.test.ts
similarity index 98%
rename from packages/system/src/components/Box/utils.test.ts
rename to packages/system/src/components/Box/selector.test.ts
index 54dc7ecf0..d78e6387d 100644
--- a/packages/system/src/components/Box/utils.test.ts
+++ b/packages/system/src/components/Box/selector.test.ts
@@ -1,4 +1,4 @@
-import { transformPseudos } from './utils';
+import { transformPseudos } from './selector';
 
 describe('transform states', () => {
   test('returns a css object', () => {
diff --git a/packages/system/src/components/Global/Global.test.tsx b/packages/system/src/components/Global/Global.test.tsx
index 9e7db8eab..4f7bc7580 100644
--- a/packages/system/src/components/Global/Global.test.tsx
+++ b/packages/system/src/components/Global/Global.test.tsx
@@ -1,60 +1,182 @@
+/* eslint-disable testing-library/no-node-access */
 import React from 'react';
-import { render } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
 import { ThemeProvider } from '../../hooks';
 
 import { Global } from './Global';
 
-test('applies normlaization to html and body', () => {
-  const view = render(<Global />);
-
-  // eslint-disable-next-line testing-library/no-node-access
-  const html = window.getComputedStyle(view.baseElement.parentElement!);
-  expect(html.height).toBe('100%');
-  // expect(html.textSizeAdjust).toBe('none'); can not test this in JSDOM :(
-  const body = window.getComputedStyle(view.baseElement);
-  expect(body.height).toBe('100%');
-  expect(body.lineHeight).toBe('1.5');
-});
-
-test('applies global styles for body and html based on `theme.root`', () => {
-  const theme = {
-    colors: {
-      background: '#fff',
-    },
-    fonts: {
-      body: 'Inter',
+const theme = {
+  colors: {
+    background: '#fff',
+    hot: 'hotpink',
+  },
+  fonts: {
+    body: 'Inter',
+  },
+  lineHeights: {
+    body: 2.5,
+  },
+  fontWeights: {
+    body: 500,
+  },
+  root: {
+    body: {
+      fontFamily: 'body',
+      lineHeight: 'body',
+      fontWeight: 'body',
     },
-    lineHeights: {
-      body: 2.5,
+    html: {
+      bg: 'background',
     },
-    fontWeights: {
-      body: 500,
-      html: 700,
+    p: {
+      bg: 'hot',
     },
-    root: {
-      body: {
-        fontFamily: 'body',
-        lineHeight: 'body',
-        fontWeight: 'body',
-      },
-      html: {
-        bg: 'background',
-      },
-    },
-  };
+  },
+};
+
+test('normalize document (html, body)', () => {
+  render(<Global />);
+
+  const html = document.querySelector('html')!;
+  expect(html).toHaveStyle('height: 100%');
+
+  const body = document.querySelector('body')!;
+  expect(body).toHaveStyle('height: 100%');
+  expect(body).toHaveStyle('line-height: 1.5');
+});
+
+test('opt out of document normalization', () => {
+  render(<Global normalizeDocument={false} />);
+
+  const html = document.querySelector('html')!;
+  expect(html).not.toHaveStyle('height: 100%');
+
+  const body = document.querySelector('body')!;
+  expect(body).not.toHaveStyle('height: 100%');
+  expect(body).not.toHaveStyle('line-height: 1.5');
+});
+
+test('normalize elements', () => {
+  render(
+    <>
+      <Global />
+      <div data-testid="div">Div</div>
+      <p data-testid="p">Paragraph</p>
+      <a data-testid="a">Link</a>
+      <button data-testid="button">Button</button>
+      <img data-testid="img" alt="foo" src="" />
+    </>
+  );
+
+  const div = screen.getByTestId('div');
+  expect(div).toHaveStyle('box-sizing: border-box');
+  expect(div).toHaveStyle('margin: 0');
+
+  const p = screen.getByTestId('p');
+  expect(p).toHaveStyle('box-sizing: border-box');
+  expect(p).toHaveStyle('margin: 0');
+  expect(p).toHaveStyle('overflow-wrap: break-word');
+
+  const a = screen.getByTestId('a');
+  expect(a).toHaveStyle('box-sizing: border-box');
+  expect(a).toHaveStyle('margin: 0');
+  expect(a).toHaveStyle('text-decoration: none');
+
+  const button = screen.getByTestId('button');
+  expect(button).toHaveStyle('box-sizing: border-box');
+  expect(button).toHaveStyle('margin: 0');
+  expect(button).toHaveStyle('display: block');
+  expect(button).toHaveStyle('appearance: none');
+  expect(button).toHaveStyle('font: inherit');
+  expect(button).toHaveStyle('background: transparent');
+  expect(button).toHaveStyle('text-align: center');
+
+  const img = screen.getByTestId('img');
+  expect(img).toHaveStyle('box-sizing: border-box');
+  expect(img).toHaveStyle('margin: 0');
+  expect(img).toHaveStyle('display: block');
+  expect(img).toHaveStyle('max-width: 100%');
+});
+
+test('apply element normalization to a selector (instead of the whole document)', () => {
+  render(
+    <>
+      <Global selector="#root" />
+      <p data-testid="outside">Paragraph</p>
+      <div id="root" data-testid="root">
+        <p data-testid="inside">Paragraph</p>
+      </div>
+    </>
+  );
+
+  const outside = screen.getByTestId('outside');
+  expect(outside).not.toHaveStyle('box-sizing: border-box');
+  expect(outside).not.toHaveStyle('margin: 0');
+  expect(outside).not.toHaveStyle('overflow-wrap: break-word');
 
-  const view = render(
+  // Uncomment when JSDOM supports `:where` selector
+  // const inside = screen.getByTestId('inside');
+  // expect(inside).toHaveStyle('box-sizing: border-box');
+  // expect(inside).toHaveStyle('margin: 0');
+  // expect(inside).toHaveStyle('overflow-wrap: break-word');
+});
+
+test('apply global styles via theme ("theme.root")', () => {
+  render(
     <ThemeProvider theme={theme}>
       <Global />
     </ThemeProvider>
   );
 
-  // eslint-disable-next-line testing-library/no-node-access
-  const html = view.baseElement.parentElement;
+  const html = document.querySelector('html')!;
   expect(html).toHaveStyle(`background: ${theme.colors.background}`);
 
-  const body = view.baseElement;
+  const body = document.querySelector('body')!;
   expect(body).toHaveStyle(`font-family: ${theme.fonts.body}`);
   expect(body).toHaveStyle(`line-height: ${theme.lineHeights.body}`);
   expect(body).toHaveStyle(`font-weight: ${theme.fontWeights.body}`);
 });
+
+test('apply root styles to a selector', () => {
+  render(
+    <ThemeProvider theme={theme}>
+      <Global selector="#root" />
+      <p data-testid="outside">Paragraph</p>
+      <div id="root" data-testid="root">
+        <p data-testid="inside">Paragraph</p>
+      </div>
+    </ThemeProvider>
+  );
+
+  const html = document.querySelector('html')!;
+  expect(html).not.toHaveStyle(`background: ${theme.colors.background}`);
+
+  const outside = screen.getByTestId('outside');
+  expect(outside).not.toHaveStyle(`background: ${theme.colors.hot}`);
+
+  // Uncomment when JSDOM supports `:where` selector
+  // const inside = screen.getByTestId('inside');
+  // expect(inside).toHaveStyle(`background: ${theme.colors.hot}`);
+});
+
+test('allow to style arbitrary elements with root styles', () => {
+  const customTheme = {
+    root: {
+      '.btn': {
+        background: 'cyan',
+      },
+    },
+  };
+
+  render(
+    <ThemeProvider theme={customTheme}>
+      <Global />
+      <button className="btn" data-testid="button">
+        Paragraph
+      </button>
+    </ThemeProvider>
+  );
+
+  const button = screen.getByTestId('button');
+  expect(button).toHaveStyle('background: cyan');
+});
diff --git a/packages/system/src/components/SVG/SVG.test.tsx b/packages/system/src/components/SVG/SVG.test.tsx
index 96f32448e..d8d98da99 100644
--- a/packages/system/src/components/SVG/SVG.test.tsx
+++ b/packages/system/src/components/SVG/SVG.test.tsx
@@ -19,13 +19,6 @@ test('renders svg', () => {
   expect(svg instanceof SVGElement).toBeTruthy();
 });
 
-test('normalizes <svg>', () => {
-  render(<SVG data-testid="svg" />);
-  const svg = screen.getByTestId('svg');
-  expect(svg).toHaveStyle('display: block');
-  expect(svg).toHaveStyle('max-width: 100%');
-});
-
 test('supports default fill color', () => {
   render(
     <SVG data-testid="svg">
diff --git a/packages/system/src/normalize.test.tsx b/packages/system/src/normalize.test.tsx
deleted file mode 100644
index 838a49877..000000000
--- a/packages/system/src/normalize.test.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { normalize, getNormalizedStyles } from './normalize';
-
-test.each(Object.entries(normalize))('base is included in %p', (_, value) => {
-  expect(value).toMatchObject(normalize.base);
-});
-
-test('get normalized styles', () => {
-  expect(getNormalizedStyles('a')).toMatchObject(normalize.a);
-  expect(getNormalizedStyles('p')).toMatchObject(normalize.p);
-});
-
-test('return base normalzation for arbitrary components', () => {
-  const Component = () => null;
-  expect(getNormalizedStyles(Component)).toMatchObject(normalize.base);
-});