Skip to content
Twilight React Playground
ثيم رائدaren

Replace the product card

Beginner8 min

Draw your own card in every product grid by registering it, then overriding product:card, in app/router.tsx.

Goal: every product grid, slider and listing in the store draws your own product card instead of the engine's.

Mechanism: the component registry, a table that pairs a name with a component. Before it draws a card, the engine's ProductCard looks up the name product:card. When a theme has overridden that name, the engine renders the theme's component instead, handing it exactly the props it received (product, layout, index…). You change one file of your own and one line group in app/router.tsx; no engine page is copied.

1. Try it

The same product from the demo store, drawn both ways. Nothing here touches the registry: both cards are rendered directly so you can compare them.

Loading products from the demo store…

And the registry calls themselves, on a playground name: switch to “override only” to watch the override get ignored.

Registry calls on a playground key, and what ProductCard would do if the same calls were made on product:card.Try this: switch to "override only": the key resolves to MyCard, but getOriginal is null, so ProductCard keeps its default.
Storefront canvas · en · LTR
Runs in the browser…
Controls
What a theme writes
import { registry } from '@salla.sa/twilight-theme-engine';
import { MyProductCard } from './components/MyProductCard';

// app/router.tsx: at module scope, before createRouter()
registry.register('product:card', MyProductCard);
registry.override('product:card', MyProductCard);

// Where the engine renders <ProductCard product={…} /> (grids, sliders, listings),
// MyProductCard receives the same props, but only after both calls.

2. Write the card

A card is a component that receives ProductCardProps. Use the engine's building blocks (Link, Image, useMoney, t()) so links, images, prices and languages behave like the rest of the store.

app/components/product/MyProductCard.tsx
import { memo } from 'react';
import { Image, Link } from '@salla.sa/twilight-theme-engine/components/common';
import type { ProductCardProps } from '@salla.sa/twilight-theme-engine/components/product';
import { useMoney } from '@salla.sa/twilight-theme-engine/hooks/useMoney';
import { useTranslation } from '@salla.sa/twilight-theme-engine/i18n';

/**
 * The theme's own product card. Registered under `product:card`, it is drawn
 * wherever the engine draws a card, with the same props.
 *
 * It is a full replacement: rendering the engine's ProductCard in here would
 * render this card again, forever.
 */
export const MyProductCard = memo(function MyProductCard({
  product,
  className = '',
  imagePriority = false,
}: ProductCardProps) {
  const { format } = useMoney();
  const { t } = useTranslation();
  const onSale = product.is_on_sale && product.sale_price > 0;

  return (
    <article className={`flex flex-col gap-2 rounded-md border border-gray-200 p-3 ${className}`}>
      <Link to={product.url} aria-label={product.name}>
        <Image
          src={product.image?.url}
          alt={product.image?.alt || product.name}
          width={400}
          height={400}
          aspectRatio="1/1"
          className="h-full w-full rounded"
          priority={imagePriority}
        />
      </Link>
      <h3 className="text-sm font-bold leading-6">
        <Link to={product.url}>{product.name}</Link>
      </h3>
      <p className="flex flex-wrap items-baseline gap-2">
        <span className="font-bold text-primary">
          {format(onSale ? product.sale_price : product.price)}
        </span>
        {onSale && <del className="text-sm text-gray-500">{format(product.regular_price)}</del>}
      </p>
      {!product.is_available && (
        <span className="text-xs text-red-700">
          {t('pages.products.out_of_stock', 'Out of stock')}
        </span>
      )}
    </article>
  );
});

3. Register, then override

app/router.tsx (excerpt)
import { registry } from '@salla.sa/twilight-theme-engine';
import { createRouter } from '@salla.sa/twilight-theme-engine/tanstack';
import { routeTree } from './routeTree.gen';
import { MyProductCard } from './components/product/MyProductCard';

// Both calls, in this order, at module scope (before any card can render):
registry.register('product:card', MyProductCard); // 1. something must already be under the name
registry.override('product:card', MyProductCard); // 2. now the name counts as overridden

export function getRouter() {
  return createRouter(routeTree);
}
In engine terms

ProductCard (src/components/product/ProductCard.tsx) runs registry.getOriginal('product:card') !== null once per mount, inside useMemo(…, []), and renders registry.resolve('product:card') only when that is true. registry.override(name, component) stores the previous entry as original only when the name already had one; on an empty name it just calls register, which stores no original (src/components/ComponentRegistry.ts). app/router.tsx is evaluated on the server and in the browser before getRouter() runs, so both renders draw the same card and hydration matches.

Traps

Go deeper: registry, ProductCard, the concept Overriding an engine component, and the lesson Swapping a component: the registry. To add something next to the card rather than replace it, a hook slot is simpler.