diff --git a/.changeset/few-houses-grow.md b/.changeset/few-houses-grow.md
new file mode 100644
index 00000000000..abfa07751d1
--- /dev/null
+++ b/.changeset/few-houses-grow.md
@@ -0,0 +1,29 @@
+---
+"@aws-amplify/ui-react": minor
+"@aws-amplify/ui": minor
+---
+
+feat(theming) add custom component style rendering
+
+```jsx
+const customComponentTheme = defineComponentTheme({
+ name: 'custom-component',
+ theme(tokens) {
+ return {
+ color: tokens.colors.red[10]
+ }
+ }
+});
+
+export function CustomComponent() {
+ return (
+ <>
+
+
+ // This will create a style tag with only the styles in the component theme
+ // the styles are scoped to the global theme
+
+ >
+ )
+}
+```
diff --git a/packages/react/__tests__/__snapshots__/exports.ts.snap b/packages/react/__tests__/__snapshots__/exports.ts.snap
index de8d2ffb9e8..2d0d524fcea 100644
--- a/packages/react/__tests__/__snapshots__/exports.ts.snap
+++ b/packages/react/__tests__/__snapshots__/exports.ts.snap
@@ -122,6 +122,7 @@ exports[`@aws-amplify/ui-react/internal exports should match snapshot 1`] = `
exports[`@aws-amplify/ui-react/server exports should match snapshot 1`] = `
[
+ "ComponentStyle",
"ThemeStyle",
"createComponentClasses",
"createTheme",
diff --git a/packages/react/src/components/ThemeProvider/ComponentStyle.tsx b/packages/react/src/components/ThemeProvider/ComponentStyle.tsx
new file mode 100644
index 00000000000..dac347e478b
--- /dev/null
+++ b/packages/react/src/components/ThemeProvider/ComponentStyle.tsx
@@ -0,0 +1,53 @@
+import * as React from 'react';
+import { WebTheme, createComponentCSS } from '@aws-amplify/ui';
+import {
+ BaseComponentProps,
+ ElementType,
+ ForwardRefPrimitive,
+ Primitive,
+ PrimitiveProps,
+} from '../../primitives/types';
+import { primitiveWithForwardRef } from '../../primitives/utils/primitiveWithForwardRef';
+import { BaseComponentTheme } from '@aws-amplify/ui';
+import { Style } from './Style';
+
+interface BaseComponentStyleProps extends BaseComponentProps {
+ /**
+ * Provide a server generated nonce which matches your CSP `style-src` rule.
+ * This will be attached to the generated tag and add a
+ The answer depends on whether the code is rendered on the client or server side.
+
+ Client side
+ - To prevent XSS inside of the tag, it will still be interpreted as CSS text by the browser.
+ - Therefore, there is not an XSS vulnerability on the client side.
+
+ Server side
+ - When React code is rendered on the server side (e.g., NextJS), the code is sent to the browser as HTML text.
+ - Therefore, it *IS* possible to insert a closing tag and escape the CSS context, which opens an XSS vulnerability.
+
+ Q: How are we mitigating the potential attack vector?
+ A: To fix this potential attack vector on the server side, we need to filter out any closing tags,
+ as this the only way to escape from the context of the browser interpreting the text as CSS.
+ We also need to catch cases where there is any kind of whitespace character , such as tabs, carriage returns, etc:
+
+ Therefore, by only rendering CSS text which does not include a closing '' tag,
+ we ensure that the browser will correctly interpret all the text as CSS.
+ */
+ if (cssText === undefined || /<\/style/i.test(cssText)) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+/**
+ * @experimental
+ * [📖 Docs](https://ui.docs.amplify.aws/react/components/theme)
+ */
+export const Style: ForwardRefPrimitive =
+ primitiveWithForwardRef(StylePrimitive);
+
+Style.displayName = 'Style';
diff --git a/packages/react/src/components/ThemeProvider/ThemeStyle.tsx b/packages/react/src/components/ThemeProvider/ThemeStyle.tsx
index 96c22b5b3b3..bce840dfe95 100644
--- a/packages/react/src/components/ThemeProvider/ThemeStyle.tsx
+++ b/packages/react/src/components/ThemeProvider/ThemeStyle.tsx
@@ -8,6 +8,7 @@ import {
PrimitiveProps,
} from '../../primitives/types';
import { primitiveWithForwardRef } from '../../primitives/utils/primitiveWithForwardRef';
+import { Style } from './Style';
interface BaseStyleThemeProps extends BaseComponentProps {
/**
@@ -29,65 +30,15 @@ const ThemeStylePrimitive: Primitive = (
if (!theme) return null;
const { name, cssText } = theme;
- /*
- Only inject theme CSS variables if given a theme.
- The CSS file users import already has the default theme variables in it.
- This will allow users to use the provider and theme with CSS variables
- without having to worry about specificity issues because this stylesheet
- will likely come after a user's defined CSS.
-
- Q: Why are we using dangerouslySetInnerHTML?
- A: We need to directly inject the theme's CSS string into the tag and add a
- The answer depends on whether the code is rendered on the client or server side.
-
- Client side
- - To prevent XSS inside of the tag, it will still be interpreted as CSS text by the browser.
- - Therefore, there is not an XSS vulnerability on the client side.
-
- Server side
- - When React code is rendered on the server side (e.g., NextJS), the code is sent to the browser as HTML text.
- - Therefore, it *IS* possible to insert a closing tag and escape the CSS context, which opens an XSS vulnerability.
-
- Q: How are we mitigating the potential attack vector?
- A: To fix this potential attack vector on the server side, we need to filter out any closing tags,
- as this the only way to escape from the context of the browser interpreting the text as CSS.
- We also need to catch cases where there is any kind of whitespace character , such as tabs, carriage returns, etc:
-
- Therefore, by only rendering CSS text which does not include a closing '' tag,
- we ensure that the browser will correctly interpret all the text as CSS.
- */
- if (/<\/style/i.test(cssText)) {
- return null;
- } else {
- return (
-
- );
- }
+ return (
+
+ );
};
/**
diff --git a/packages/react/src/components/ThemeProvider/__tests__/ComponentStyle.test.tsx b/packages/react/src/components/ThemeProvider/__tests__/ComponentStyle.test.tsx
new file mode 100644
index 00000000000..9fc3a0295cd
--- /dev/null
+++ b/packages/react/src/components/ThemeProvider/__tests__/ComponentStyle.test.tsx
@@ -0,0 +1,43 @@
+import { render } from '@testing-library/react';
+import * as React from 'react';
+
+import { ComponentStyle } from '../ComponentStyle';
+import { createTheme, defineComponentTheme } from '@aws-amplify/ui';
+
+describe('ComponentStyle', () => {
+ it('does not render anything if no theme is passed', async () => {
+ // @ts-expect-error - missing props
+ const { container } = render();
+
+ const styleTag = container.querySelector(`style`);
+ expect(styleTag).toBe(null);
+ });
+
+ it('does not render anything if no component themes are passed', async () => {
+ // @ts-expect-error - missing props
+ const { container } = render();
+
+ const styleTag = container.querySelector(`style`);
+ expect(styleTag).toBe(null);
+ });
+
+ it('renders a style tag if theme and component themes are passed', async () => {
+ const testComponentTheme = defineComponentTheme({
+ name: 'test',
+ theme(tokens) {
+ return {
+ color: tokens.colors.red[100],
+ };
+ },
+ });
+ const { container } = render(
+
+ );
+
+ const styleTag = container.querySelector(`style`);
+ expect(styleTag).toBeInTheDocument();
+ });
+});
diff --git a/packages/react/src/server.ts b/packages/react/src/server.ts
index f4151365813..4db727c346a 100644
--- a/packages/react/src/server.ts
+++ b/packages/react/src/server.ts
@@ -1,4 +1,5 @@
export { ThemeStyle } from './components/ThemeProvider/ThemeStyle';
+export { ComponentStyle } from './components/ThemeProvider/ComponentStyle';
export {
createTheme,
defineComponentTheme,
diff --git a/packages/ui/src/theme/createTheme/__tests__/__snapshots__/defineComponentTheme.test.ts.snap b/packages/ui/src/theme/createTheme/__tests__/__snapshots__/defineComponentTheme.test.ts.snap
new file mode 100644
index 00000000000..772e1c8d85f
--- /dev/null
+++ b/packages/ui/src/theme/createTheme/__tests__/__snapshots__/defineComponentTheme.test.ts.snap
@@ -0,0 +1,12 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`@aws-amplify/ui defineComponentTheme should return a cssText function 1`] = `
+"[data-amplify-theme="default-theme"] .amplify-test { background-color:pink; border-radius:var(--amplify-radii-small); }
+[data-amplify-theme="default-theme"] .amplify-test--small { border-radius:0; }
+"
+`;
+
+exports[`@aws-amplify/ui defineComponentTheme should return a cssText function that works with custom tokens 1`] = `
+"[data-amplify-theme="test"] .amplify-test { background-color:var(--amplify-colors-hot-pink-10); }
+"
+`;
diff --git a/packages/ui/src/theme/createTheme/__tests__/createComponentCSS.test.ts b/packages/ui/src/theme/createTheme/__tests__/createComponentCSS.test.ts
index b2b12425d00..c394466210c 100644
--- a/packages/ui/src/theme/createTheme/__tests__/createComponentCSS.test.ts
+++ b/packages/ui/src/theme/createTheme/__tests__/createComponentCSS.test.ts
@@ -105,21 +105,27 @@ describe('@aws-amplify/ui', () => {
const theme = {
padding: '20px',
};
- const css = createComponentCSS(
- `test`,
- [
+ const css = createComponentCSS({
+ theme: {
+ name: 'test',
+ tokens,
+ breakpoints,
+ },
+ components: [
{
name: 'testing',
theme,
},
],
- tokens,
- breakpoints
- );
+ });
- const functionCSS = createComponentCSS(
- `test`,
- [
+ const functionCSS = createComponentCSS({
+ theme: {
+ name: 'test',
+ tokens,
+ breakpoints,
+ },
+ components: [
{
name: 'testing',
theme(tokens) {
@@ -127,17 +133,19 @@ describe('@aws-amplify/ui', () => {
},
},
],
- tokens,
- breakpoints
- );
+ });
expect(css).toMatchSnapshot();
expect(functionCSS).toMatchSnapshot();
});
it('should work with custom tokens', () => {
- const css = createComponentCSS(
- 'test',
- [
+ const css = createComponentCSS({
+ theme: {
+ name: 'test',
+ tokens: customTokens,
+ breakpoints,
+ },
+ components: [
{
name: 'avatar',
theme(tokens) {
@@ -147,16 +155,18 @@ describe('@aws-amplify/ui', () => {
},
},
],
- customTokens,
- breakpoints
- );
+ });
expect(css).toMatchSnapshot();
});
it('should pass through raw values', () => {
- const css = createComponentCSS(
- `test`,
- [
+ const css = createComponentCSS({
+ theme: {
+ name: 'test',
+ tokens,
+ breakpoints,
+ },
+ components: [
{
name: 'badge',
theme: (tokens) => {
@@ -172,22 +182,31 @@ describe('@aws-amplify/ui', () => {
},
},
],
- tokens,
- breakpoints
- );
+ });
expect(css).toMatchSnapshot();
});
it('should work with built-in components', () => {
expect(
- createComponentCSS(`test`, [avatarTheme], tokens, breakpoints)
+ createComponentCSS({
+ theme: {
+ name: 'test',
+ tokens,
+ breakpoints,
+ },
+ components: [avatarTheme],
+ })
).toMatchSnapshot();
});
it('can use custom primitives', () => {
- const css = createComponentCSS(
- 'test',
- [
+ const css = createComponentCSS({
+ theme: {
+ name: 'test',
+ tokens,
+ breakpoints,
+ },
+ components: [
{
name: 'chip',
theme: {
@@ -219,9 +238,7 @@ describe('@aws-amplify/ui', () => {
},
},
],
- tokens,
- breakpoints
- );
+ });
expect(css).toMatchSnapshot();
});
});
diff --git a/packages/ui/src/theme/createTheme/__tests__/defineComponentTheme.test.ts b/packages/ui/src/theme/createTheme/__tests__/defineComponentTheme.test.ts
new file mode 100644
index 00000000000..32edd0e9576
--- /dev/null
+++ b/packages/ui/src/theme/createTheme/__tests__/defineComponentTheme.test.ts
@@ -0,0 +1,50 @@
+import { createTheme } from '../createTheme';
+import { defineComponentTheme } from '../defineComponentTheme';
+
+const theme = createTheme();
+const customTheme = createTheme({
+ name: 'test',
+ tokens: {
+ colors: {
+ hotPink: {
+ 10: '#f90',
+ },
+ },
+ },
+});
+
+describe('@aws-amplify/ui', () => {
+ describe('defineComponentTheme', () => {
+ it('should return a cssText function', () => {
+ const testComponentTheme = defineComponentTheme({
+ name: 'test',
+ theme(tokens) {
+ return {
+ backgroundColor: 'pink',
+ borderRadius: '{radii.small}',
+ _modifiers: {
+ small: {
+ borderRadius: '0',
+ },
+ },
+ };
+ },
+ });
+ expect(testComponentTheme.cssText({ theme })).toMatchSnapshot();
+ });
+
+ it('should return a cssText function that works with custom tokens', () => {
+ const testComponentTheme = defineComponentTheme({
+ name: 'test',
+ theme(tokens) {
+ return {
+ backgroundColor: tokens.colors.hotPink[10],
+ };
+ },
+ });
+ expect(
+ testComponentTheme.cssText({ theme: customTheme })
+ ).toMatchSnapshot();
+ });
+ });
+});
diff --git a/packages/ui/src/theme/createTheme/createComponentCSS.ts b/packages/ui/src/theme/createTheme/createComponentCSS.ts
index 028d67084f4..b29afc86aa1 100644
--- a/packages/ui/src/theme/createTheme/createComponentCSS.ts
+++ b/packages/ui/src/theme/createTheme/createComponentCSS.ts
@@ -58,17 +58,21 @@ function recursiveComponentCSS(baseSelector: string, theme: BaseTheme) {
return str;
}
+interface CreateComponentCSSParams {
+ theme: Pick;
+ components: Array;
+}
+
/**
* This will take a component theme and create the appropriate CSS for it.
*
*/
-export function createComponentCSS(
- themeName: string,
- components: Array,
- tokens: WebTheme['tokens'],
- breakpoints: DefaultTheme['breakpoints']
-) {
+export function createComponentCSS({
+ theme,
+ components,
+}: CreateComponentCSSParams) {
let cssText = '';
+ const { tokens, name: themeName, breakpoints } = theme;
components.forEach(({ name, theme, overrides }) => {
const baseComponentClassName = `amplify-${name}`;
diff --git a/packages/ui/src/theme/createTheme/createTheme.ts b/packages/ui/src/theme/createTheme/createTheme.ts
index fd5e5a27b8b..51c17be63f0 100644
--- a/packages/ui/src/theme/createTheme/createTheme.ts
+++ b/packages/ui/src/theme/createTheme/createTheme.ts
@@ -74,12 +74,13 @@ export function createTheme(
`\n}\n`;
if (theme?.components) {
- cssText += createComponentCSS(
- name,
- theme.components,
- tokens,
- mergedTheme.breakpoints
- );
+ cssText += createComponentCSS({
+ theme: {
+ ...mergedTheme,
+ tokens,
+ },
+ components: theme.components,
+ });
}
let overrides: Array = [];
diff --git a/packages/ui/src/theme/createTheme/defineComponentTheme.ts b/packages/ui/src/theme/createTheme/defineComponentTheme.ts
index b16a6ee14c4..e919549e48b 100644
--- a/packages/ui/src/theme/createTheme/defineComponentTheme.ts
+++ b/packages/ui/src/theme/createTheme/defineComponentTheme.ts
@@ -5,6 +5,8 @@ import {
ComponentThemeOverride,
} from '../components/utils';
import { WebTokens } from '../tokens';
+import { WebTheme } from '../types';
+import { createComponentCSS } from './createComponentCSS';
import {
createComponentClasses,
ClassNameFunction,
@@ -74,16 +76,30 @@ export function defineComponentTheme<
theme: typeof theme;
name: string;
overrides?: typeof overrides;
+ cssText: (props: {
+ theme: Pick;
+ }) => string;
} {
const prefix = 'amplify-';
const className = createComponentClasses({
name,
prefix,
});
+
+ const cssText = (props: {
+ theme: Pick;
+ }) => {
+ return createComponentCSS({
+ theme: props.theme,
+ components: [{ name, theme }],
+ });
+ };
+
return {
className,
theme,
overrides,
name,
+ cssText,
};
}
diff --git a/packages/ui/src/theme/createTheme/index.ts b/packages/ui/src/theme/createTheme/index.ts
index 212f92c5f0d..33c4457a2b6 100644
--- a/packages/ui/src/theme/createTheme/index.ts
+++ b/packages/ui/src/theme/createTheme/index.ts
@@ -1,5 +1,6 @@
export { createTheme } from './createTheme';
export { defineComponentTheme } from './defineComponentTheme';
+export { createComponentCSS } from './createComponentCSS';
export {
cssNameTransform,
setupTokens,
diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts
index 28272e99eca..a94e01d93f9 100644
--- a/packages/ui/src/theme/index.ts
+++ b/packages/ui/src/theme/index.ts
@@ -2,11 +2,14 @@ export {
createTheme,
defineComponentTheme,
createComponentClasses,
+ createComponentCSS,
cssNameTransform,
isDesignToken,
setupTokens,
SetupToken,
} from './createTheme';
+
+export { BaseComponentTheme } from './components';
export { defaultTheme } from './defaultTheme';
export {
defaultDarkModeOverride,