Skip to content

Customize a component

A feature package gives you a working component. Your design asks for something else. There are four levels of change. Start at the lowest one that does the job: each level up gives you more control, and costs you more updates.

Level You change When Evolve updates
1. Style it classes, CSS, tokens, labels, provider props You keep everything.
2. Rearrange the parts which parts render, in what order and wrapper You keep the parts, not the default arrangement.
3. Take over a part the markup of one part You keep the logic, not the markup.
4. Swap a primitive a ui primitive, such as Button, in every feature at once You own that primitive.

This guide uses one running example: the cart page from @evolve-storefront/feature-cart, as the reference B2C storefront customizes it.

  • Your app’s stylesheet must import the package’s styles.css. Without it, the package’s Tailwind classes are not generated, and nothing tells you so.

    /* src/app/global.css */
    @import "@evolve-storefront/commerce-ui/styles.css";
    @import "@evolve-storefront/feature-cart/styles.css";

    The cart needs commerce-ui too, because CartSummary renders its PriceSummary parts.

  • Every feature package ships its source under src/ and a CUSTOMIZING.md next to its README.md, in node_modules/@evolve-storefront/<package>/. The CUSTOMIZING.md lists the package’s data-component names, tokens and seams.

Every component and every part takes a className. It is merged with tailwind-merge, so your class replaces a conflicting default instead of fighting it.

// The project makes the column sticky itself, so it turns off the summary's own sticky.
<CartSummary cart={cart} checkout={checkoutLink} className="@4xl:static" />

The elements you may style carry a data-component attribute. Target it from your global CSS to change the component everywhere it renders. These rules come from the reference B2C storefront:

/* src/app/global.css */
[data-component="cart"] {
/* Clears the fixed header. */
--cart-summary-top: 6rem;
}
/* A wider summary column. The skeleton uses the same element, so it follows. */
[data-component="cart-layout"] {
@apply @4xl:grid-cols-[minmax(0,1fr)_26rem];
}
/* One card per line instead of dividers. */
[data-component="cart-line-items"] {
@apply gap-4 divide-y-0;
}

These rules win over the package’s classes because they sit outside a cascade layer, and Tailwind puts its utilities in @layer utilities.

The cart’s names are listed in node_modules/@evolve-storefront/feature-cart/CUSTOMIZING.md. The state attributes you may combine with them (data-disabled, data-current, …) are listed in the README of @evolve-storefront/ui, under “The state vocabulary you may style against”.

The cart has no colors of its own. It reads the shared tokens (--color-foreground, --color-border, --color-primary, …), so you change those once for the whole storefront. See Style and brand.

All cart copy comes from the Cart messages namespace. Override a key in your own messages to change a label. See Add a language.

Behavior is configured on the CartProvider your app already mounts, not by changing components. For example, keep the “removed. Cancel” row longer, and show a toast after every add to cart:

import { CartProvider } from "@evolve-storefront/feature-cart/provider";
import { toast } from "sonner";
<CartProvider
undoTimeout={5000}
onLineItemsAdded={({ addedItems }) => toast.success(`${addedItems[0]?.productName} added`)}
>
{children}
</CartProvider>

The feature-cart reference lists every provider prop. For the ways to confirm an add, see Confirm an add to cart.

Nothing. data-component names are public API, like props: a rename is a breaking change and is in the changelog.

Cart with no children renders its default composition: a two-column grid with the lines on the left and the summary on the right.

Some changes still fit the default composition. Pass them to Cart:

<Cart
cart={cart}
checkout={<Link href="/checkout">Checkout</Link>}
renderLink={(line, children) => (
<Link href={`/products/${line.productSlug}`}>{children}</Link>
)}
/>

When you need a different arrangement, pass children. The parts are exported from the same subpath as Cart. This is a trimmed version of the reference B2C cart page:

import {
Cart,
CartDiscountCodeForm,
CartGrid,
CartLineItems,
CartSummary,
CartSummaryBar,
} from "@evolve-storefront/feature-cart/cart";
import { useCart } from "@evolve-storefront/feature-cart/hooks";
import Link from "next/link";
export function CartPage() {
const { data: cart } = useCart();
if (!cart) return null;
const checkout = <Link href="/checkout">Checkout</Link>;
return (
<Cart cart={cart}>
<CartGrid>
<div className="flex flex-col gap-8">
<CartLineItems cart={cart} />
<Link href="/">Continue shopping</Link>
</div>
<CartSummary cart={cart} checkout={checkout}>
<CartDiscountCodeForm cart={cart} />
</CartSummary>
<CartSummaryBar cart={cart} checkout={checkout} />
</CartGrid>
</Cart>
);
}

Things to know when you compose:

  • Pass cart to every part. Parts do not read it from Cart. Pass vatDisplay to each part too if you show net prices.
  • With children, Cart ignores checkout, renderLink and renderItem. Set them on the parts instead (CartSummary, CartSummaryBar, CartLineItems).
  • Keep CartSummaryBar a direct child of CartGrid. It looks for the CartSummary in the same grid to know when to show itself.
  • CartSummary has slots for project content: checkout, paymentMethods, usps, and children for the discount code form.
  • Use the skeletons (CartSkeleton, CartLineItemsSkeleton, …) and CartEmpty for the loading and empty states.

The arrangement is yours now. The parts inside it still update. But when a release changes the default composition, for example to add a part, your page does not get that change. When you upgrade, check the package’s CHANGELOG.md for changes to Cart and CartLayout (the default composition).

Sometimes the design moves things around inside a part, and no prop or CSS rule reaches that far. The reference B2C cart row is an example: the quantity sits beside the product instead of under it, and a struck-through original price sits above the amount.

CartLineItems has a seam for this: renderItem replaces the row and leaves the list in charge of the data and the writes.

  1. Copy the shipped row from node_modules/@evolve-storefront/feature-cart/src/components/parts/cart-line-item.tsx into your project, for example src/app/[locale]/(main)/cart/_components/cart-line.tsx. Note the package version you copied it from.

  2. Replace the relative imports with package imports. The types come from the package root (@evolve-storefront/feature-cart) and from @evolve-storefront/feature-cart/cart.

  3. Take the writes from the list’s api instead of from props:

    "use client";
    import type { CartLineItem } from "@evolve-storefront/feature-cart";
    import type { CartLineItemApi } from "@evolve-storefront/feature-cart/cart";
    import { QuantityStepper } from "@evolve-storefront/ui/quantity-stepper";
    import { useTranslations } from "next-intl";
    export function CartLine({ line, api }: { line: CartLineItem; api: CartLineItemApi }) {
    const t = useTranslations("Cart.line");
    return (
    <li data-component="cart-line-item" className="grid grid-cols-[auto_1fr_auto_auto] gap-6">
    {/* image, name, prices: your markup */}
    <QuantityStepper
    value={line.quantity}
    onValueChange={api.setQuantity}
    min={1}
    disabled={api.isUpdatingQuantity}
    label={t("quantity-label", { product: line.productName })}
    decrementLabel={t("quantity-decrease", { product: line.productName })}
    incrementLabel={t("quantity-increase", { product: line.productName })}
    />
    <button
    type="button"
    data-component="cart-line-item-remove"
    onClick={api.remove}
    disabled={api.isRemoving}
    >
    {t("remove")}
    </button>
    </li>
    );
    }
  4. Render it from the list:

    <CartLineItems cart={cart} renderItem={(line, api) => <CartLine line={line} api={api} />} />
  5. Change the markup to match your design.

Why the api and not your own useRemoveLineItem: api.remove goes through the list’s undo window, so the list still shows the “removed. Cancel” row. A row with its own remove hook would start a second undo window that the list cannot see.

Keep the data-component names and the Cart.line message keys. Your global CSS keeps applying, and your translations keep working.

The markup of that part. Fixes and improvements to the shipped row no longer reach you: accessible labels, stock messages, price calculations. An earlier release fixed a row that multiplied the line total by the quantity; a copy made before that fix would have kept the bug. When you upgrade, compare your copy with the package’s new cart-line-item.tsx.

You keep everything else: the query, the mutations, the undo window and the messages all stay in the package and keep updating.

Your project may have its own design system, with its own Button, and want every feature to use it. Feature packages import the ui primitives by their subpath, such as @evolve-storefront/ui/button. Point that subpath at your own module, and every feature renders yours.

This is the last resort. Your bundler resolves the alias once for the whole build, so it cannot differ per site, and a visual change buys nothing that tokens and data-component CSS do not. Use it only when you need different markup everywhere.

  1. Write a wrapper that keeps the primitive’s API, and renders your own component. Map the variant and tone the features pass to your own:

    import type { ButtonProps } from "@evolve-storefront/ui/button";
    import { Button as BrandButton } from "@/components/button";
    export function Button({ variant = "primary", tone, ...props }: ButtonProps) {
    return <BrandButton {...props} variant={tone === "destructive" ? "error" : brandVariants[variant]} />;
    }
    const brandVariants = { primary: "transactional", secondary: "main", tertiary: "outline" } as const;

    Export every name the subpath exports, because a feature may import any of them. That includes buttonProps, which the content blocks call to make a link look like a button: return your own classes from it, with the same data-component, data-variant and data-tone attributes.

  2. Point the subpath at it in next.config.ts:

    const nextConfig = {
    turbopack: {
    resolveAlias: {
    "@evolve-storefront/ui/button": "./src/components/ui-button.tsx",
    },
    },
    };

The alias reaches only imports of the subpath. Inside ui, the dropdown menu trigger and the buttons of a confirm dialog use the shipped buttonProps directly, so they keep the shipped look. Style those with [data-component="button"] CSS.

You also give up the primitive. Fixes to @evolve-storefront/ui/button no longer reach any feature, and a new prop a feature starts passing does nothing until your wrapper handles it. When you upgrade, compare the subpath’s exports and props with your wrapper. Read Theming the site for when an alias is the right call.