Components and Theming

Plan: Composable Developer

Lesson 9 of 19 · 45 min

Introduction

The default Catalyst theme is built on a design system of modern React components for use in composable websites, built and maintained by BigCommerce.

This component system is designed specifically for ecommerce, providing beautiful, out-of-the-box UI for every part of the commerce experience. The library includes general, low-level components like buttons and accordions, as well as orchestrating these lower level components into more sophisticated presentations like product cards, product lists, and a site header.

The component library leverages modern patterns and the latest features of Next.js, and it is optimized for fast performance. The clean, practical prop interfaces of its components make them flexible and reusable in any context where they’re needed within your UI.

The look and feel of your storefront is easily style-able without the need for modifying component code. However, all theme components are also included directly in your project, ready to be customized to suit your own requirements.

File Structure

The theme components are found in vibes/soul within your Catalyst project. This includes several major sub-sections:

  • form: Contains streamlined implementations of common form controls
  • lib: Contains general utilities that are used within various components
  • primitives: Contains unique component implementations for various pieces of UI, from the smallest general building blocks like buttons to more complex UI like accordions and product cards
  • sections: Components in this area represent larger UI sections built from the more primitive components or other sections, such as a “product detail” section built by orchestrating together a product gallery component, rating component, product form component, etc.

Tailwind CSS

Tailwind CSS is a popular CSS framework that enables rapid and reliable styling and visual layouts with a system of utility classes.

With Tailwind, instead of writing raw CSS, you chain together utility classes to accomplish the styling you need, as in this example from vibes/soul/sections/cart/index.tsx:

<div>
{/* Utility classes dealing with margins and text formatting */}
<h1 className="mb-10 ... text-4xl font-medium leading-none @xl:text-5xl">
{title}
...
</h1>
...
{/* Utility classes dealing with flexbox layout */}
<ul className="flex flex-col gap-5 ...">
...

Tailwind is used by Next.js by default, and Catalyst components fully embrace Tailwind styling.

Styling Components

The Tailwind styles used throughout your theme components apply a consistent look and feel through a set of standardized values for text styles, font sizes, and color palette. It’s easy to customize the theme of your storefront without changing the basic structure and behavior of its components, using Tailwind and CSS configuration.

  • tailwind.config.js: This config file contains Tailwind’s basic configuration, including the definition of common styles in theme.extend. These include named styles for things like colors and fonts. Most styles in this config are tied to CSS variables that are defined elsewhere, but you expand your available styles by adding new key names.
  • globals.css: The CSS variables in this file provide the values for the styles defined in tailwind.config.js. You have a great deal of flexibility for adjusting your storefront’s theme simply by modifying these vars.
  • app/fonts.ts: Font family definitions are loaded here. By default, available fonts are loaded via the package next/font/google. The CSS variables applied to each font family here are utilized for standard font styles in the Tailwind config.

Makeswift Style Customization

In your own codebase, you may notice that globals.css doesn’t define all the CSS variables used in tailwind.config.js. Color values, for example, are not defined here.

With Makeswift integrated, many hard-coded CSS variables are replaced with common color and font styles defined in the builder. These can then be applied to the properties of specific component types, allowing the storefront-wide look and feel to be modified without touching a line of code.

Explore more details about managing global styles in Makeswift Core.

Try It Out

Try practicing the use of global styles and configuration to modify your Catalyst theme.

  1. Browse to a product detail page on your local site.
  2. Modify globals.css to change the value of --font-size-base to a larger or smaller value.
  3. Observe the effect on the page’s “Add to cart” button.

Note in tailwind.config.js where the CSS variable is used: for the fontSize.base configuration. A Tailwind text size class expressed as text-base should reflect this config. You can observe where this very class is used in the definition of the Button component (in the theme directory, in primitives/button/index.tsx).

  1. Try adding your own fontSize value in tailwind.config.js, which can have an arbitrary key:
const config = {
...
theme: {
extend: {
...
fontSize: {
'2xs': '0.5rem',
...
},
},
},
...
};
  1. Open the file where the Button component is defined and replace occurrences of text-base with text-2xs.
  2. Observe the effect on the page’s “Add to cart” button.

To follow the typical Catalyst pattern, you can take this further by establishing a global CSS variable for this size in app/globals.css, and reference this in your Tailwind config.

Component Variation

Component CSS Variables

Theme components implement a number of specialized CSS variables that can be used to control the general look and feel of those components, globally or in an isolated context.

This example can be seen in vibes/soul/primitives/card/index.tsx:

/**
* This component supports various CSS variables for theming. Here's a comprehensive list, along
* with their default values:
*
* ```css
* :root {
* --card-focus: hsl(var(--primary));
* --card-light-offset: hsl(var(--background));
* --card-light-text: hsl(var(--foreground));
* --card-light-icon: hsl(var(--foreground));
* --card-light-background: hsl(var(--contrast-100));
* --card-dark-offset: hsl(var(--foreground));
* --card-dark-text: hsl(var(--background));
* --card-dark-icon: hsl(var(--background));
* --card-dark-background: hsl(var(--contrast-500));
* --card-font-family: var(--font-family-body);
* --card-border-radius: 1rem;
* }
* ```
*/
export function Card({
...
}: CardProps) {

Taking --card-border-radius as an example, the Card component will fall back to a default if this CSS variable is not set but will use its value otherwise. You can take advantage of this in two ways:

  • Set a value for --card-border-radius globally.
  • In a project not integrated with Makeswift, this can be done in app/globals.css.
  • With the Makeswift integration, global values for these component CSS vars are set by properties in the Makeswift editor, in the Site Theme element. Adding these variables to app/globals.css will have no effect.
  • Set a unique value for --card-border-radius directly on an ancestor element in specific context, affecting only a Card in that context.

Explore more details about the process of theming components with Makeswift in Makeswift Core.

Variants

Many components include multiple variants.

See an example in the simple Button component (vibes/soul/primitives/button/index.tsx). This component defines “primary”, “secondary” and “tertiary” variants, controlled by a prop, each of which is associated with its own unique styles.

Including a specific component variant is then as simple as passing a variant prop:

import { Button } from '../../../../../../vibes/soul/primitives/button';
export default function MyComponent() {
return (
<Button variant="secondary">Click Me</Button>
);
}

Color Scheme

Similarly to the concept of a variant, some components accept a prop called colorScheme (or more specific props like textColorScheme).

import { ProductCard } from '../../../../../../vibes/soul/primitives/product-card';
export default function MyComponent() {
return (
<ProductCard colorScheme="dark" ... />
);
}

The use of this prop will result in the component applying default styles suitable for “light” or “dark” mode. Note that, for example, ProductCard makes use of CSS vars --product-card-light-background and --product-card-dark-background, selectively applying the appropriate one for the current color scheme.

Try It Out

Practice the use of targeted CSS variables to control the appearance of a component in a specific context.

The Button component supports the CSS variable --button-primary-background to set the background color when the “primary” variant is rendered. A global value for this var (set either in app/globals.css or via the Site Theme configuration in Makeswift) will apply to all buttons in the storefront. However, we can set it in a narrower context if required.

  1. Browse to a product detail page on your local site.
  2. Open the file app/[locale]/(default)/product/[slug]/page.tsx and find <ProductDetail>.
  3. Wrap <ProductDetail> in an element with an appropriate style prop to set the button background CSS var.
<div
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
style={{
'--button-primary-background': '#f00',
} as React.CSSProperties}
>
<ProductDetail
...
/>
</div>
  1. Observe the effect on your page’s “Add to cart” button.

This is a bit of a contrived example, as you will likely want to keep a consistent look and feel for buttons throughout the theme. If you do need additional appearance options beyond the existing variants, it would be advisable to modify your Button component definition to add new variants.

The Toaster Component

The Toaster component enables simple and unobtrusive UI notifications displayed to users in response to their interactions, such as a success or error message after an “add to cart” action completes, is an important UI concern but can be cumbersome to build out from scratch.

The necessary layout config is built into Catalyst by default, and notifications can be initiated from anywhere:

import { toast } from '../../../../../../vibes/soul/primitives/toaster';
toast.success('This is a success message');
toast.error('This is an error message');

Asynchronous Rendering with Stream

As we’ve seen, Suspense boundaries can be used to render components asynchronously, allowing their content to be streamed to the browser when ready.

Catalyst enhances this capability with the component Stream (see vibes/soul/lib/streamable.tsx). Stream, which uses Suspense internally, accomplishes several design goals while supporting an excellent developer experience with data components:

  • Components that handle their own fallbacks - The page route or parent component shouldn’t need to concern itself with whether a fallback is needed. This is a concern of the component itself.
  • Components that are data agnostic - Components should be concerned only with presentation, not how the data is loaded. This makes for more reusable components that can support a variety of data loading strategies.
  • Support for both synchronous and asynchronous data - Components shouldn’t be concerned with how data is fetched, seamlessly handling either loaded data for a Promise for that data.
  • Server-side and client-side compatibility - It should be possible to drop a component into a React server component or a client-side context, with any Promise being resolved appropriately in either context.

In this simple example, the TopCategories component expects a “streamable” list of categories, meaning that the prop may be a Promise yet to be resolved or may be a simple array. The <Stream> component and its callback function take care of displaying a fallback if necessary until the streamable value is available, and the wrapped JSX can deal with categories in typical fashion.

import { Stream, Streamable } from '../../../../../../vibes/soul/lib/streamable';
interface Category {
...
}
export async function TopCategories({
categories: streamableCategories,
}: {
// Accept a Streamable
categories: Streamable<Category[]>;
}) {
return <>
// Wrap UI in <Stream> with a streamable value
<Stream value={streamableCategories} fallback={<FallbackComponent />}>
{(categories) => (
<div>...</div>
)}
</Stream>
</>;
}

This pattern keeps the concern of fallback behavior within the component while also avoiding assumptions about receiving a Promise. The data fetching is a concern outside the component.

In the context where the data fetch is set up, the recommended pattern is to use the Streamable.from callback to create a Promise that will not be executed until the component ultimately awaits it.

import { Streamable } from '../../../../../../vibes/soul/lib/streamable';
export async function PageRoute() {
// Wrapping the fetch in Streamable.from
const streamableCategories = Streamable.from(async () => {
return await getTopCategories();
});
return <>
<TopCategories categories={streamableCategories} />
</>;
}