diff --git a/.eslintrc.js b/.eslintrc.js
index 7b5ebef5325..8121a04e34b 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -52,6 +52,7 @@ module.exports = {
'prop-types',
'react',
'requireindex',
+ 'react-transition-group',
],
'import/resolver': {
node: {},
diff --git a/assets/js/base/components/cart-checkout/shipping-rates-control/index.tsx b/assets/js/base/components/cart-checkout/shipping-rates-control/index.tsx
index bd4970e16f5..b735a1724c8 100644
--- a/assets/js/base/components/cart-checkout/shipping-rates-control/index.tsx
+++ b/assets/js/base/components/cart-checkout/shipping-rates-control/index.tsx
@@ -4,10 +4,7 @@
import { __ } from '@wordpress/i18n';
import { useEffect } from '@wordpress/element';
import LoadingMask from '@woocommerce/base-components/loading-mask';
-import {
- ExperimentalOrderShippingPackages,
- StoreNotice,
-} from '@woocommerce/blocks-checkout';
+import { ExperimentalOrderShippingPackages } from '@woocommerce/blocks-checkout';
import {
getShippingRatesPackageCount,
getShippingRatesRateCount,
@@ -17,6 +14,7 @@ import {
useEditorContext,
useShippingData,
} from '@woocommerce/base-context';
+import NoticeBanner from '@woocommerce/base-components/notice-banner';
/**
* Internal dependencies
@@ -111,7 +109,7 @@ const ShippingRatesControl = ( {
{ hasSelectedLocalPickup &&
shippingRates.length > 1 &&
! isEditor && (
-
+
) }
- { isAddressComplete &&
- __(
- 'There are no shipping options available. Please check your shipping address.',
- 'woo-gutenberg-products-block'
- ) }
+ { isAddressComplete && (
+
+ { __(
+ 'There are no shipping options available. Please check your shipping address.',
+ 'woo-gutenberg-products-block'
+ ) }
+
+ ) }
>
}
shippingRates={ shippingRates }
diff --git a/assets/js/base/components/index.ts b/assets/js/base/components/index.ts
new file mode 100644
index 00000000000..aa445113079
--- /dev/null
+++ b/assets/js/base/components/index.ts
@@ -0,0 +1,40 @@
+export * from './block-error-boundary';
+export * from './button';
+export * from './cart-checkout';
+export * from './checkbox-list';
+export * from './chip';
+export * from './combobox';
+export * from './country-input';
+export * from './drawer';
+export * from './filter-element-label';
+export * from './filter-placeholder';
+export * from './filter-reset-button';
+export * from './filter-submit-button';
+export * from './form';
+export * from './form-token-field';
+export * from './formatted-monetary-amount';
+export * from './label';
+export * from './load-more-button';
+export * from './loading-mask';
+export * from './noninteractive';
+export * from './notice-banner';
+export * from './pagination';
+export * from './price-slider';
+export * from './product-list';
+export * from './product-name';
+export * from './product-price';
+export * from './product-rating';
+export * from './quantity-selector';
+export * from './radio-control';
+export * from './radio-control-accordion';
+export * from './read-more';
+export * from './reviews';
+export * from './sidebar-layout';
+export * from './snackbar-list';
+export * from './sort-select';
+export * from './spinner';
+export * from './state-input';
+export * from './summary';
+export * from './tabs';
+export * from './textarea';
+export * from './title';
diff --git a/assets/js/base/components/notice-banner/README.md b/assets/js/base/components/notice-banner/README.md
new file mode 100644
index 00000000000..9345d194672
--- /dev/null
+++ b/assets/js/base/components/notice-banner/README.md
@@ -0,0 +1,138 @@
+# NoticeBanner Component
+
+An informational UI displayed near the top of the store pages.
+
+## Table of contents
+
+- [Design Guidelines](#design-guidelines)
+- [Development Guidelines](#development-guidelines)
+ - [Usage](#usage)
+ - [Props](#props)
+ - [`children`: `React.ReactNode`](#children-reactreactnode)
+ - [`className`: `string`](#classname-string)
+ - [`isDismissible`: `boolean`](#isdismissible-boolean)
+ - [`onRemove`: `() => void`](#onremove---void)
+ - [`politeness`: `'polite' | 'assertive'`](#politeness-polite--assertive)
+ - [`spokenMessage`: `string`](#spokenmessage-string)
+ - [`status`: `'success' | 'error' | 'info' | 'warning' | 'default'`](#status-success--error--info--warning--default)
+ - [`summary`: `string`](#summary-string)
+ - [Example](#example)
+
+## Design Guidelines
+
+Notices are informational UI displayed near the top of store pages. Notices are used to indicate the result of an action, or to draw the user’s attention to necessary information.
+
+Notices are color-coded to indicate the type of message being communicated, and also show an icon to reinforce the meaning of the message. The color and icon used for a notice are determined by the `status` prop.
+
+### Informational
+
+Blue notices used for general information for buyers that are not blocking and do not require action.
+
+![Informational notice](./screenshots/info.png)
+
+### Error
+
+Red notices to show that an error has occurred and that the user needs to take action.
+
+![Error notice](./screenshots/error.png)
+
+### Success
+
+Green notices that show an action was successful.
+
+![Success notice](./screenshots/success.png)
+
+### Warning
+
+Yellow notices that show that the user may need to take action, or needs to be aware of something important.
+
+![Warning notice](./screenshots/warning.png)
+
+### Default
+
+Gray notice, similar to info, but used for less important messaging.
+
+![Default notice](./screenshots/default.png)
+
+## Development Guidelines
+
+### Usage
+
+To display a plain notice, pass the notice message as a string:
+
+```jsx
+import { NoticeBanner } from '@woocommerce/base-components';
+
+Your message here;
+```
+
+For more complex markup, you can pass any JSX element:
+
+```jsx
+import { NoticeBanner } from '@woocommerce/base-components';
+
+
+
+ An error occurred: { errorDetails }.
+
+;
+```
+
+### Props
+
+#### `children`: `React.ReactNode`
+
+The displayed message of a notice. Also used as the spoken message for assistive technology, unless `spokenMessage` is provided as an alternative message.
+
+#### `className`: `string`
+
+Additional class name to give to the notice.
+
+#### `isDismissible`: `boolean`
+
+Determines whether the notice can be dismissed by the user. When set to true, a close icon will be displayed on the banner.
+
+#### `onRemove`: `() => void`
+
+Function called when dismissing the notice. When the close icon is clicked or the Escape key is pressed, this function will be called.
+
+#### `politeness`: `'polite' | 'assertive'`
+
+Determines the level of politeness for the notice for assistive technology. Acceptable values are 'polite' and 'assertive'. Default is 'polite'.
+
+#### `spokenMessage`: `string`
+
+Optionally provided to change the spoken message for assistive technology. If not provided, the `children` prop will be used as the spoken message.
+
+#### `status`: `'success' | 'error' | 'info' | 'warning' | 'default'`
+
+Status determines the color of the notice and the icon. Acceptable values are `success`, `error`, `info`, `warning`, and `default`.
+
+#### `summary`: `string`
+
+Optional summary text shown above notice content, used when several notices are listed together.
+
+##### Example
+
+```tsx
+import { NoticeBanner } from '@woocommerce/base-components';
+
+const errorMessages = [
+ 'First error message',
+ 'Second error message',
+ 'Third error message',
+];
+
+
+
+ { errorMessages.map( ( message ) => (
+
{ message }
+ ) ) }
+
+;
+```
+
+In this example, the summary prop is used to indicate to the user that there are errors in the form submission. The list of error messages is rendered within the NoticeBanner component using an unordered list (`
`) and list items (`
`). The `status` prop is set to `error` to indicate that the notice represents an error message.
diff --git a/assets/js/base/components/notice-banner/index.tsx b/assets/js/base/components/notice-banner/index.tsx
new file mode 100644
index 00000000000..50dcbac641b
--- /dev/null
+++ b/assets/js/base/components/notice-banner/index.tsx
@@ -0,0 +1,99 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import { __ } from '@wordpress/i18n';
+import { Icon, close } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import { getDefaultPoliteness, getStatusIcon } from './utils';
+import Button from '../button';
+import { useSpokenMessage } from '../../hooks';
+
+export interface NoticeBannerProps {
+ // The displayed message of a notice. Also used as the spoken message for assistive technology, unless `spokenMessage` is provided as an alternative message.
+ children: React.ReactNode;
+ // Additional class name to give to the notice.
+ className?: string | undefined;
+ // Determines whether the notice can be dismissed by the user.
+ isDismissible?: boolean | undefined;
+ // Function called when dismissing the notice.
+ onRemove?: ( () => void ) | undefined;
+ // Determines the level of politeness for the notice for assistive technology.
+ politeness?: 'polite' | 'assertive' | undefined;
+ // Optionally provided to change the spoken message for assistive technology.
+ spokenMessage?: string | React.ReactNode | undefined;
+ // Status determines the color of the notice and the icon.
+ status: 'success' | 'error' | 'info' | 'warning' | 'default';
+ // Optional summary text shown above notice content, used when several notices are listed together.
+ summary?: string | undefined;
+}
+
+/**
+ * NoticeBanner: An informational UI displayed near the top of the store pages.
+ *
+ * Notices are informational UI displayed near the top of store pages. WooCommerce blocks, themes, and plugins all use
+ * notices to indicate the result of an action, or to draw the user’s attention to necessary information.
+ */
+const NoticeBanner = ( {
+ className,
+ status = 'default',
+ children,
+ spokenMessage = children,
+ onRemove = () => void 0,
+ isDismissible = true,
+ politeness = getDefaultPoliteness( status ),
+ summary,
+}: NoticeBannerProps ) => {
+ useSpokenMessage( spokenMessage, politeness );
+
+ const dismiss = ( event: React.SyntheticEvent ) => {
+ if (
+ typeof event?.preventDefault === 'function' &&
+ event.preventDefault
+ ) {
+ event.preventDefault();
+ }
+ onRemove();
+ };
+
+ return (
+
+
+
+ { summary && (
+
+ { summary }
+
+ ) }
+ { children }
+
+ { !! isDismissible && (
+
+ ) }
+
+ );
+};
+
+export default NoticeBanner;
diff --git a/assets/js/base/components/notice-banner/screenshots/default.png b/assets/js/base/components/notice-banner/screenshots/default.png
new file mode 100644
index 00000000000..b332186648e
Binary files /dev/null and b/assets/js/base/components/notice-banner/screenshots/default.png differ
diff --git a/assets/js/base/components/notice-banner/screenshots/error.png b/assets/js/base/components/notice-banner/screenshots/error.png
new file mode 100644
index 00000000000..4de65910933
Binary files /dev/null and b/assets/js/base/components/notice-banner/screenshots/error.png differ
diff --git a/assets/js/base/components/notice-banner/screenshots/info.png b/assets/js/base/components/notice-banner/screenshots/info.png
new file mode 100644
index 00000000000..de4ebc5124b
Binary files /dev/null and b/assets/js/base/components/notice-banner/screenshots/info.png differ
diff --git a/assets/js/base/components/notice-banner/screenshots/success.png b/assets/js/base/components/notice-banner/screenshots/success.png
new file mode 100644
index 00000000000..dcb4536ee19
Binary files /dev/null and b/assets/js/base/components/notice-banner/screenshots/success.png differ
diff --git a/assets/js/base/components/notice-banner/screenshots/warning.png b/assets/js/base/components/notice-banner/screenshots/warning.png
new file mode 100644
index 00000000000..8fcf176155b
Binary files /dev/null and b/assets/js/base/components/notice-banner/screenshots/warning.png differ
diff --git a/assets/js/base/components/notice-banner/stories/index.tsx b/assets/js/base/components/notice-banner/stories/index.tsx
new file mode 100644
index 00000000000..202a0088e5e
--- /dev/null
+++ b/assets/js/base/components/notice-banner/stories/index.tsx
@@ -0,0 +1,107 @@
+/**
+ * External dependencies
+ */
+import type { Story, Meta } from '@storybook/react';
+
+/**
+ * Internal dependencies
+ */
+import NoticeBanner, { NoticeBannerProps } from '../';
+const availableStatus = [ 'default', 'success', 'error', 'warning', 'info' ];
+
+export default {
+ title: 'WooCommerce Blocks/@base-components/NoticeBanner',
+ argTypes: {
+ status: {
+ control: 'radio',
+ options: availableStatus,
+ description:
+ 'Status determines the color of the notice and the icon.',
+ },
+ isDismissible: {
+ control: 'boolean',
+ description: 'Determines whether the notice can be dismissed.',
+ },
+ summary: {
+ description:
+ 'Optional summary text shown above notice content, used when several notices are listed together.',
+ control: 'text',
+ },
+ className: {
+ description: 'Additional class name to give to the notice.',
+ control: 'text',
+ },
+ spokenMessage: {
+ description:
+ 'Optionally provided to change the spoken message for assistive technology.',
+ control: 'text',
+ },
+ politeness: {
+ control: 'radio',
+ options: [ 'polite', 'assertive' ],
+ description:
+ 'Determines the level of politeness for the notice for assistive technology.',
+ },
+ children: {
+ description:
+ 'The content of the notice; either text or a React node such as a list of errors.',
+ disable: true,
+ },
+ onRemove: {
+ description: 'Function called when dismissing the notice.',
+ disable: true,
+ },
+ },
+ component: NoticeBanner,
+} as Meta< NoticeBannerProps >;
+
+const Template: Story< NoticeBannerProps > = ( args ) => {
+ return ;
+};
+
+export const Default = Template.bind( {} );
+Default.args = {
+ children: 'This is a default notice',
+ status: 'default',
+ isDismissible: true,
+ summary: undefined,
+ className: undefined,
+ spokenMessage: undefined,
+ politeness: undefined,
+};
+
+export const Error = Template.bind( {} );
+Error.args = {
+ children: 'This is an error notice',
+ status: 'error',
+};
+
+export const Warning = Template.bind( {} );
+Warning.args = {
+ children: 'This is a warning notice',
+ status: 'warning',
+};
+
+export const Info = Template.bind( {} );
+Info.args = {
+ children: 'This is an info notice',
+ status: 'info',
+};
+
+export const Success = Template.bind( {} );
+Success.args = {
+ children: 'This is a success notice',
+ status: 'success',
+};
+
+export const ErrorSummary = Template.bind( {} );
+ErrorSummary.args = {
+ summary: 'Please fix the following errors',
+ children: (
+
+
This is an error notice
+
This is another error notice
+
+ ),
+ status: 'error',
+};
diff --git a/assets/js/base/components/notice-banner/style.scss b/assets/js/base/components/notice-banner/style.scss
new file mode 100644
index 00000000000..395de052cb4
--- /dev/null
+++ b/assets/js/base/components/notice-banner/style.scss
@@ -0,0 +1,149 @@
+%notice-banner {
+ display: flex;
+ align-items: stretch;
+ align-content: flex-start;
+ color: $gray-800;
+ padding: $gap !important;
+ gap: $gap-small;
+ margin: $gap 0;
+ border-radius: 4px;
+ border-color: $gray-800;
+ font-weight: 400;
+ line-height: 1.5;
+ border: 1px solid;
+ @include font-size(small);
+ background-color: #fff;
+ box-sizing: border-box;
+
+ > .wc-block-components-notice-banner__content {
+ padding-right: $gap;
+ align-self: center;
+ white-space: normal;
+ flex-basis: 100%;
+
+ &:last-child {
+ padding-right: 0;
+ }
+
+ .wc-block-components-notice-banner__summary {
+ margin: 0 0 $gap-smaller;
+ font-weight: 600;
+ }
+
+ ul,
+ ol {
+ margin: 0 0 0 $gap-large;
+ padding: 0;
+
+ li::after {
+ content: "";
+ clear: both;
+ display: block;
+ }
+ }
+
+ // Legacy notice compatibility.
+ .wc-forward.wp-element-button {
+ float: right;
+ color: $gray-800 !important;
+ background: transparent;
+ padding: 0 !important;
+ margin: 0;
+ border: 0;
+ appearance: none;
+ opacity: 0.6;
+
+ &:hover,
+ &:focus,
+ &:active {
+ opacity: 1;
+ }
+ }
+ }
+
+ > svg {
+ fill: #fff;
+ border-radius: 50%;
+ padding: 2px;
+ background-color: $gray-800;
+ flex-shrink: 0;
+ flex-grow: 0;
+ }
+
+ > .wc-block-components-button {
+ margin: 6px 0 0 auto !important;
+ background: transparent none !important;
+ box-shadow: none !important;
+ outline: none !important;
+ border: 0 !important;
+ padding: 0 !important;
+ height: 16px !important;
+ width: 16px !important;
+ min-height: auto !important;
+ color: $gray-800 !important;
+ min-width: 0 !important;
+ flex: 0 0 16px;
+ opacity: 0.6;
+
+ > svg {
+ margin: 0 !important;
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ opacity: 1;
+ }
+ }
+}
+
+%error {
+ border-color: $alert-red;
+ background-color: #fff0f0;
+
+ > svg {
+ background-color: $alert-red;
+ transform: rotate(180deg);
+ }
+}
+%warning {
+ border-color: $alert-yellow;
+ background-color: #fffbf4;
+
+ > svg {
+ background-color: $alert-yellow;
+ transform: rotate(180deg);
+ }
+}
+%success {
+ border-color: $alert-green;
+ background-color: #f4fff7;
+
+ > svg {
+ background-color: $alert-green;
+ }
+}
+%info {
+ border-color: #007cba;
+ background-color: #f4f8ff;
+
+ > svg {
+ background-color: #007cba;
+ }
+}
+
+.wc-block-components-notice-banner {
+ @extend %notice-banner;
+ &.is-error {
+ @extend %error;
+ }
+ &.is-warning {
+ @extend %warning;
+ }
+ &.is-success {
+ @extend %success;
+ }
+ &.is-info {
+ @extend %info;
+ }
+}
diff --git a/assets/js/base/components/notice-banner/test/index.tsx b/assets/js/base/components/notice-banner/test/index.tsx
new file mode 100644
index 00000000000..ce9f2722cf6
--- /dev/null
+++ b/assets/js/base/components/notice-banner/test/index.tsx
@@ -0,0 +1,99 @@
+/**
+ * External dependencies
+ */
+import { render, fireEvent, findByText } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import NoticeBanner from '../index';
+
+describe( 'NoticeBanner', () => {
+ test( 'renders without errors when all required props are provided', async () => {
+ const { container } = render(
+ This is an error message
+ );
+ expect(
+ await findByText( container, 'This is an error message' )
+ ).toBeInTheDocument();
+ } );
+
+ test( 'displays the notice message correctly', () => {
+ const message = 'This is a test message';
+ const { getByText } = render(
+
+ { message }
+
+ );
+ const messageElement = getByText( message );
+ expect( messageElement ).toBeInTheDocument();
+ } );
+
+ test( 'displays the correct status for the notice', () => {
+ const { container } = render(
+
+ This is a warning message
+
+ );
+ expect( container.querySelector( '.is-warning' ) ).toBeInTheDocument();
+ } );
+
+ test( 'displays the summary correctly when provided', () => {
+ const summaryText = '4 new messages';
+ const { getByText } = render(
+
+ This is a test message
+
+ );
+ const summaryElement = getByText( summaryText );
+ expect( summaryElement ).toBeInTheDocument();
+ } );
+
+ test( 'can be dismissed when isDismissible prop is true', () => {
+ const onRemoveMock = jest.fn();
+ const { getByRole } = render(
+
+ This is a success message
+
+ );
+ const closeButton = getByRole( 'button' );
+ fireEvent.click( closeButton );
+ expect( onRemoveMock ).toHaveBeenCalled();
+ } );
+
+ test( 'calls onRemove function when the notice is dismissed', () => {
+ const onRemoveMock = jest.fn();
+ const { getByRole } = render(
+
+ This is an informative message
+
+ );
+ const closeButton = getByRole( 'button' );
+ fireEvent.click( closeButton );
+ expect( onRemoveMock ).toHaveBeenCalled();
+ } );
+
+ test( 'applies the className prop to the notice', () => {
+ const customClassName = 'my-custom-class';
+ const { container } = render(
+
+ This is a success message
+
+ );
+ const noticeElement = container.firstChild;
+ expect( noticeElement ).toHaveClass( customClassName );
+ } );
+
+ test( 'does not throw any errors when all props are provided correctly', () => {
+ const spyError = jest.spyOn( console, 'error' );
+ render(
+ This is a test message
+ );
+ expect( spyError ).not.toHaveBeenCalled(); // Should not print any error/warning messages
+ spyError.mockRestore(); // Restore the original mock
+ } );
+} );
diff --git a/assets/js/base/components/notice-banner/utils.ts b/assets/js/base/components/notice-banner/utils.ts
new file mode 100644
index 00000000000..eb061664480
--- /dev/null
+++ b/assets/js/base/components/notice-banner/utils.ts
@@ -0,0 +1,37 @@
+/**
+ * External dependencies
+ */
+import { info, megaphone, check } from '@wordpress/icons';
+
+/**
+ * Get the default politeness level for a given status. This is based on how severe the status is.
+ */
+export const getDefaultPoliteness = ( status: string ) => {
+ switch ( status ) {
+ case 'success':
+ case 'warning':
+ case 'info':
+ case 'default':
+ return 'polite';
+
+ case 'error':
+ default:
+ return 'assertive';
+ }
+};
+
+/**
+ * Gets the icon for the notice from the status. Note; we spin the warning status 180 degrees to make it look like an exclamation mark.
+ */
+export const getStatusIcon = ( status: string ): JSX.Element => {
+ switch ( status ) {
+ case 'success':
+ return check;
+ case 'warning':
+ case 'info':
+ case 'error':
+ return info;
+ default:
+ return megaphone;
+ }
+};
diff --git a/assets/js/base/components/snackbar-list/README.md b/assets/js/base/components/snackbar-list/README.md
new file mode 100644
index 00000000000..b3bc398b3ea
--- /dev/null
+++ b/assets/js/base/components/snackbar-list/README.md
@@ -0,0 +1,62 @@
+# SnackbarList Component
+
+A temporary informational UI displayed at the bottom of store pages.
+
+## Table of contents
+
+- [Design Guidelines](#design-guidelines)
+- [Development Guidelines](#development-guidelines)
+ - [Usage](#usage)
+ - [Props](#props)
+ - [`className`: `string`](#classname-string)
+ - [`onRemove`: `( noticeId ) => void`](#onremove--noticeid---void)
+ - [`notices`: `NoticeType[]`](#notices-noticetype)
+
+## Design Guidelines
+
+The buyer notice snackbar is temporary informational UI displayed at the bottom of store pages. WooCommerce blocks, themes, and plugins all use snackbar notices to indicate the result of a successful action.
+
+Snackbar notices work in the same way as the NoticeBanner component, and support the same statuses and styles.
+
+## Development Guidelines
+
+### Usage
+
+To display snackbar notices, pass an array of `notices` to the `SnackbarList` component:
+
+```jsx
+import { SnackbarList } from '@woocommerce/base-components';
+
+const notices = [
+ {
+ id: '1',
+ content: 'This is a snackbar notice.',
+ status: 'default',
+ isDismissible: true,
+ }
+];
+
+;
+```
+
+The component consuming `SnackbarList` is responsible for managing the notices state. The `SnackbarList` component will automatically remove notices from the list when they are dismissed by the user using the provided `onRemove` callback, and also when the notice times out after 10000ms.
+
+### Props
+
+#### `className`: `string`
+
+Additional class name to give to the notice.
+
+#### `onRemove`: `( noticeId ) => void`
+
+Function called when dismissing the notice. When the close icon is clicked or the Escape key is pressed, this function will be called. This is also called when the notice times out after 10000ms.
+
+#### `notices`: `NoticeType[]`
+
+A list of notices to display as snackbars. Each notice must have an `id` and `content` prop.
+
+- The `id` prop is used to identify the notice and should be unique.
+- The `content` prop is the content to display in the notice.
+- The `status` prop is used to determine the color of the notice and the icon. Acceptable values are 'success', 'error', 'info', 'warning', and 'default'.
+- The `isDismissible` prop determines whether the notice can be dismissed by the user.
+- The `spokenMessage` prop is used to change the spoken message for assistive technology. If not provided, the `content` prop will be used as the spoken message.
diff --git a/assets/js/base/components/snackbar-list/constants.ts b/assets/js/base/components/snackbar-list/constants.ts
new file mode 100644
index 00000000000..bdc1149a5d4
--- /dev/null
+++ b/assets/js/base/components/snackbar-list/constants.ts
@@ -0,0 +1 @@
+export const SNACKBAR_TIMEOUT = 10000;
diff --git a/assets/js/base/components/snackbar-list/index.tsx b/assets/js/base/components/snackbar-list/index.tsx
new file mode 100644
index 00000000000..af7e92e4f5f
--- /dev/null
+++ b/assets/js/base/components/snackbar-list/index.tsx
@@ -0,0 +1,88 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import type { NoticeType } from '@woocommerce/types';
+import { useReducedMotion } from '@wordpress/compose';
+import { useRef } from '@wordpress/element';
+import { CSSTransition, TransitionGroup } from 'react-transition-group';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import Snackbar from './snackbar';
+
+export type SnackbarListProps = {
+ // Class name to be added to the container.
+ className?: string | undefined;
+ // List of notices to be rendered.
+ notices: NoticeType[];
+ // Callback to be called when a notice is dismissed.
+ onRemove: ( id: string ) => void;
+};
+
+/**
+ * A temporary informational UI displayed at the bottom of store pages.
+ */
+const SnackbarList = ( {
+ notices,
+ className,
+ onRemove = () => void 0,
+}: SnackbarListProps ): JSX.Element => {
+ const listRef = useRef< HTMLDivElement | null >( null );
+ const isReducedMotion = useReducedMotion();
+
+ const removeNotice = ( notice: NoticeType ) => () =>
+ onRemove( notice?.id || '' );
+
+ return (
+