React product page template

A React product page: an image gallery, colour and size pickers, a quantity stepper, add to cart, a details accordion and related products.

Light and dark

React product page template, in the light theme
React product page template, in the dark theme
Live demo

Install

One command writes the page, its design and its 3 sections into your project, as code you then own. It needs a components.json, and the shadcn registry page has the one-time setup.
Terminal
npx shadcn@latest add https://www.box-kite.dev/r/product-page.json

Sections

Each section is a block of its own, with its own page and its own install command.

Source

The page itself. Every style is a prop, so there is no stylesheet to copy with it, and each section it imports is on its own page above.
box-kite/store/product-page.tsx
JSX
'use client';
import Box from '@box-kite/react';
import Accordion from '@box-kite/react/components/accordion';
import Grid from '@box-kite/react/components/grid';
import { H1, H2, Li, Link, Nav, Ol, P, Section, Span, Ul } from '@box-kite/react/components/semantics';
import { useState } from 'react';
import ProductCard from './product-card';
import ProductGallery from './product-gallery';
import { countOf, formatPrice, productBySlug, products, sampleCart } from './store-data';
import { eyebrow, heading, useStoreLinks } from './store-design';
import StoreShell from './store-shell';
import VariantPicker from './variant-picker';

const muted = { color: 'stone-600', theme: { dark: { color: 'stone-400' } } } as const;

/** One product: the gallery and the picker share the finish, and adding to the cart moves the header's count. */
export default function ProductPage({ slug = 'table-lamp', theme }: { slug?: string; theme?: string }) {
  const links = useStoreLinks();
  const product = productBySlug(slug);
  const [finish, setFinish] = useState(product.finishes[0]);
  const [cartCount, setCartCount] = useState(countOf(sampleCart));
  const related = [...products.filter((entry) => entry.slug !== product.slug)]
    .sort((a, b) => Number(b.category === product.category) - Number(a.category === product.category))
    .slice(0, 4);

  return (
    <StoreShell cartCount={cartCount} theme={theme}>
      <Nav props={{ 'aria-label': 'Breadcrumb' }} mb={6} fontSize={14}>
        <Ol display="flex" listStyle="none" m={0} p={0} gap={2} flexWrap="wrap" {...muted}>
          <Li>
            <Link
              props={{ href: `${links.shop}#collection` }}
              color="currentColor"
              hover={{ color: 'stone-500' }}
              theme={{ dark: { hover: { color: 'stone-400' } } }}
            >
              Shop
            </Link>
          </Li>
          <Li props={{ 'aria-hidden': true }}>/</Li>
          <Li>{product.category}</Li>
          <Li props={{ 'aria-hidden': true }}>/</Li>
          <Li color="stone-900" theme={{ dark: { color: 'stone-100' } }} props={{ 'aria-current': 'page' }}>
            {product.name}
          </Li>
        </Ol>
      </Nav>

      <Grid gridTemplateColumns={1} lg={{ gridTemplateColumns: 2 }} gap={10} xl={{ gap: 16 }} ai="start">
        <ProductGallery product={product} finish={finish} />

        <Box>
          {product.badge && (
            <Span {...eyebrow} {...muted}>
              {product.badge}
            </Span>
          )}
          <H1 mt={3} fontSize={40} lineHeight={46} {...heading} letterSpacing={-1}>
            {product.name}
          </H1>
          <Span display="block" mt={2} fontSize={20}>
            {formatPrice(product.price)}
          </Span>
          <P mt={5} fontSize={17} lineHeight={28} {...muted}>
            {product.summary}
          </P>

          <Box mt={8}>
            <VariantPicker
              product={product}
              finish={finish}
              onFinishChange={setFinish}
              onAdd={(line) => setCartCount((count) => count + line.quantity)}
            />
          </Box>

          <Ul listStyle="none" m={0} p={0} mt={6} display="flex" d="column" gap={1.5} fontSize={14} {...muted}>
            <Li>Ships in 2–3 working days.</Li>
            <Li>Spare parts for ten years.</Li>
          </Ul>

          <Accordion id="details" mt={8} defaultValue={['materials']} level={2} css={{ scrollMarginTop: '6rem' }}>
            <Accordion.Item value="materials">
              <Accordion.Trigger>Materials and size</Accordion.Trigger>
              <Accordion.Panel>
                Solid materials only — steel, oak, stoneware and porcelain — under a matte finish that does not chip or yellow. Where an
                object comes in more than one size, the measurements are on the options above.
              </Accordion.Panel>
            </Accordion.Item>
            <Accordion.Item value="care">
              <Accordion.Trigger>Care</Accordion.Trigger>
              <Accordion.Panel>
                Dust with a dry cloth; a damp one and a drop of soap for anything more. Every part unscrews, and every part can be ordered
                again for ten years after you buy it.
              </Accordion.Panel>
            </Accordion.Item>
            <Accordion.Item value="shipping">
              <Accordion.Trigger>Shipping and returns</Accordion.Trigger>
              <Accordion.Panel>
                Free standard shipping on orders over $250. Returns are free within 30 days, and anything that arrives damaged is replaced —
                send us a photo, and keep the box.
              </Accordion.Panel>
            </Accordion.Item>
          </Accordion>
        </Box>
      </Grid>

      <Section props={{ 'aria-labelledby': 'related-heading' }} mt={20}>
        <H2 id="related-heading" fontSize={24} mb={8} {...heading}>
          You might also like
        </H2>
        <Grid gridTemplateColumns={2} lg={{ gridTemplateColumns: 4 }} columnGap={5} rowGap={8}>
          {related.map((entry) => (
            <ProductCard key={entry.slug} product={entry} />
          ))}
        </Grid>
      </Section>
    </StoreShell>
  );
}

Design

What every page of this template shares: the built-in components restyled with Box.createComponents(), applied to the subtree with <Box.Components>, so it stops at the edge of the store and the rest of your app keeps its own look.

Store design

box-kite/store/store-design.tsx
JSX
'use client';
import Box from '@box-kite/react';
import { createContext, type ReactNode, useContext } from 'react';

/**
 * The store's design: the built-in components restyled for this one brand, in light and dark. It registers
 * nothing — `Box.createComponents()` returns a design, and `StoreSurface` applies it to its subtree only, so
 * the store can sit inside an app that looks like something else.
 */
export const storeDesign = Box.createComponents({
  // Square, solid and one colour: the only filled shape on a page of hairlines, so the action is never in doubt.
  button: {
    styles: {
      bgColor: 'stone-900',
      color: 'white',
      borderRadius: 999,
      px: 6,
      fontWeight: 500,
      transition: 'colors',
      hover: { bgColor: 'stone-700' },
      active: { bgColor: 'stone-800' },
      focus: { outlineColor: 'stone-400' },
      disabled: { bgColor: 'stone-200', color: 'stone-500', hover: { bgColor: 'stone-200' } },
      theme: {
        dark: {
          bgColor: 'stone-100',
          color: 'stone-950',
          hover: { bgColor: 'white' },
          active: { bgColor: 'stone-200' },
          focus: { outlineColor: 'stone-500' },
          disabled: { bgColor: 'stone-800', color: 'stone-500', hover: { bgColor: 'stone-800' } },
        },
      },
    },
    variants: {
      secondary: {
        bgColor: 'transparent',
        color: 'stone-900',
        b: 1,
        borderColor: 'stone-300',
        hover: { bgColor: 'transparent', borderColor: 'stone-900' },
        active: { bgColor: 'stone-100' },
        focus: { borderColor: 'stone-900', outlineColor: 'stone-300' },
        theme: {
          dark: {
            bgColor: 'transparent',
            color: 'stone-100',
            borderColor: 'stone-700',
            hover: { bgColor: 'transparent', borderColor: 'stone-200' },
            active: { bgColor: 'stone-900' },
            focus: { borderColor: 'stone-300', outlineColor: 'stone-600' },
          },
        },
      },
      ghost: {
        bgColor: 'transparent',
        color: 'stone-700',
        hover: { bgColor: 'stone-900/5' },
        active: { bgColor: 'stone-900/10' },
        disabled: { bgColor: 'transparent', color: 'stone-400' },
        theme: {
          dark: {
            bgColor: 'transparent',
            color: 'stone-300',
            hover: { bgColor: 'white/10' },
            active: { bgColor: 'white/15' },
            disabled: { bgColor: 'transparent', color: 'stone-600' },
          },
        },
      },
    },
  },
  textbox: {
    styles: {
      width: 'fit',
      borderRadius: 2.5,
      borderColor: 'stone-300',
      bgColor: 'white',
      color: 'stone-900',
      hover: { borderColor: 'stone-500' },
      focus: { borderColor: 'stone-900', outlineColor: 'stone-200' },
      ariaAttr: { invalid: { borderColor: 'red-700', focus: { outlineColor: 'red-200' } } },
      theme: {
        dark: {
          bgColor: 'stone-950',
          color: 'stone-100',
          borderColor: 'stone-700',
          hover: { borderColor: 'stone-500' },
          focus: { borderColor: 'stone-200', outlineColor: 'stone-800' },
          ariaAttr: { invalid: { borderColor: 'red-400', focus: { outlineColor: 'red-900' } } },
        },
      },
    },
  },
  checkbox: {
    styles: {
      borderRadius: 1,
      borderColor: 'stone-400',
      outlineColor: 'stone-300',
      hover: { borderColor: 'stone-900' },
      checked: { bgColor: 'stone-900', borderColor: 'stone-900' },
      theme: {
        dark: {
          borderColor: 'stone-500',
          outlineColor: 'stone-700',
          hover: { borderColor: 'stone-200' },
          checked: { bgColor: 'stone-600', borderColor: 'stone-200' },
        },
      },
    },
  },
  radioButton: {
    styles: {
      borderColor: 'stone-400',
      outlineColor: 'stone-300',
      hover: { borderColor: 'stone-900' },
      checked: { bgColor: 'stone-900', borderColor: 'stone-900' },
      theme: {
        dark: {
          borderColor: 'stone-500',
          outlineColor: 'stone-700',
          hover: { borderColor: 'stone-200' },
          checked: { bgColor: 'stone-600', borderColor: 'stone-200' },
        },
      },
    },
  },
  // The caption over a row of options rather than beside it: the row wraps, and the caption takes the first line whole.
  radioGroup: {
    variants: { horizontal: { flexWrap: 'wrap' } },
    children: {
      label: { styles: { width: 'fit', fontSize: 14, fontWeight: 500, color: 'stone-900', theme: { dark: { color: 'stone-100' } } } },
    },
  },
  dropdown: {
    styles: {
      width: 'fit',
      borderRadius: 2.5,
      borderColor: 'stone-300',
      bgColor: 'white',
      color: 'stone-900',
      hover: { borderColor: 'stone-500' },
      focus: { borderColor: 'stone-900', outlineColor: 'stone-200' },
      theme: {
        dark: {
          bgColor: 'stone-950',
          color: 'stone-100',
          borderColor: 'stone-700',
          hover: { borderColor: 'stone-500' },
          focus: { borderColor: 'stone-200', outlineColor: 'stone-800' },
        },
      },
    },
    children: {
      items: {
        styles: {
          borderRadius: 3,
          borderColor: 'stone-200',
          color: 'stone-900',
          theme: { dark: { bgColor: 'stone-950', borderColor: 'stone-800', color: 'stone-100' } },
        },
      },
      item: {
        styles: {
          borderRadius: 2,
          hover: { bgColor: 'stone-100' },
          focus: { bgColor: 'stone-100' },
          selected: { bgColor: 'stone-100', hover: { bgColor: 'stone-200' } },
          theme: {
            dark: {
              hover: { bgColor: 'stone-900' },
              focus: { bgColor: 'stone-900' },
              selected: { bgColor: 'stone-900', hover: { bgColor: 'stone-800' } },
            },
          },
        },
        variants: { highlighted: { outlineColor: 'stone-900', theme: { dark: { outlineColor: 'stone-200' } } } },
      },
    },
  },
  progress: {
    styles: { height: 0.5, borderRadius: 999, bgColor: 'stone-200', theme: { dark: { bgColor: 'stone-800' } } },
    children: {
      fill: { styles: { borderRadius: 999, bgColor: 'stone-900', theme: { dark: { bgColor: 'stone-100' } } } },
    },
  },
  accordion: {
    children: {
      item: { styles: { borderColor: 'stone-200', theme: { dark: { borderColor: 'stone-800' } } } },
      trigger: {
        styles: {
          py: 4,
          fontSize: 15,
          fontWeight: 500,
          color: 'stone-900',
          hover: { color: 'stone-500' },
          ariaAttr: { expanded: { color: 'stone-900' } },
          focusVisible: { outlineColor: 'stone-900' },
          theme: { dark: { color: 'stone-100', hover: { color: 'stone-400' }, ariaAttr: { expanded: { color: 'stone-100' } } } },
        },
      },
      panel: { styles: { fontSize: 15, lineHeight: 24, color: 'stone-600', theme: { dark: { color: 'stone-400' } } } },
    },
  },
  tabs: {
    children: {
      tab: { styles: { focusVisible: { outlineColor: 'stone-900' } } },
      panel: { styles: { focusVisible: { outlineColor: 'stone-900' } } },
    },
  },
});

/** Spread on a heading: a medium weight and tight tracking, in whatever sans the system already has. */
export const heading = { fontWeight: 500, letterSpacing: -0.5 } as const;

/** The small spaced capitals over a section, beside a badge or under a price. */
export const eyebrow = { fontSize: 12, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase' } as const;

/** A panel on the page: a hairline and soft corners, nothing else. */
export const card = { b: 1, borderRadius: 4, borderColor: 'stone-200', theme: { dark: { borderColor: 'stone-800' } } } as const;

/** Secondary text, in both themes. */
export const muted = { color: 'stone-500', theme: { dark: { color: 'stone-400' } } } as const;

/**
 * Where the pages and blocks link to. A provider rather than constants, so the same pages work under any
 * router: wrap them in `<StoreLinks.Provider value={…}>` with your own routes.
 */
export interface StoreRoutes {
  shop: string;
  product: (slug: string) => string;
  cart: string;
  checkout: string;
}

export const StoreLinks = createContext<StoreRoutes>({
  shop: '/',
  product: (slug) => `/products/${slug}`,
  cart: '/cart',
  checkout: '/checkout',
});

export const useStoreLinks = () => useContext(StoreLinks);

/** The design and the page colours, with no theme of its own: it follows whatever theme is around it. */
export function StoreSurface({ children }: { children: ReactNode }) {
  return (
    <Box.Components value={storeDesign}>
      <Box bgColor="white" color="stone-900" theme={{ dark: { bgColor: 'stone-950', color: 'stone-100' } }}>
        {children}
      </Box>
    </Box.Components>
  );
}

/**
 * A whole store page: its own light/dark switch, remembered under its own key, around `StoreSurface`.
 * Without `theme` it opens in the reader's saved choice, then their system's.
 */
export function StoreTheme({ theme, children }: { theme?: string; children: ReactNode }) {
  return (
    <Box.Theme theme={theme} storageKey="still-theme">
      <StoreSurface>
        <Box minHeight="fit-screen">{children}</Box>
      </StoreSurface>
    </Box.Theme>
  );
}
The command installs the rest of it beside that file — store-data.ts, product-art.tsx, quantity-stepper.tsx, form-field.tsx, store-shell.tsx — and the repository has every file.