ecomcn
All blocks

Browse/block

Quick view

Quick view with a shared-element morph: the card image grows into the dialog and shrinks back on close. Motion's layoutId drives the morph, a native <dialog> does the modal work, focus returns to the trigger, and reduced-motion users get no movement. The one ecomcn block that depends on motion — opt-in.

Step 1Register the namespace — once per project

$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.json

Step 2Add the block

$ npx shadcn@latest add @ecomcn/product-quick-view

Step 1 is not optional for this one — it pulls another ecomcn block, and the CLI has no way to resolve @ecomcn/… until the namespace is registered.

Preview — live
100%

Usage

A listing with quick view
import {
  ProductQuickView,
  ProductQuickViewImage,
  ProductQuickViewTrigger,
} from "@/components/ecomcn/product-quick-view"
import {
  ProductCard,
  ProductCardBody,
  ProductCardImage,
  ProductCardMedia,
  useProductCard,
} from "@/components/ecomcn/product-card"

<ProductQuickView onAddToBag={(product, colorIndex) => addToBag(product.id, colorIndex)}>
  <ProductGrid
    products={products}
    renderCard={(product) => (
      <ProductCard product={product}>
        <ProductCardMedia>
          <ProductQuickViewImage product={product}>
            <ProductCardImage />          {/* this is what morphs */}
          </ProductQuickViewImage>
          <QuickViewButton />
        </ProductCardMedia>
        <ProductCardBody />
      </ProductCard>
    )}
  />
</ProductQuickView>

// Open on the colour the shopper already picked on the card.
function QuickViewButton() {
  const { product, colorIndex } = useProductCard()
  return <ProductQuickViewTrigger product={product} colorIndex={colorIndex} />
}

Props & context

Read straight out of the source at build time, so this cannot drift from the file you install.

ProductQuickViewContextValue
active: ActiveView | null
  open: (
    product: ProductCardProduct,
    options?: { colorIndex?: number; trigger?: HTMLElement | null }
  ) => void
  close: () => void
  /** The shared id that links a card's image to the dialog's. */
  layoutId: (productId: string) => string
ProductQuickViewProps
/** The listing — cards containing ProductQuickViewImage and ProductQuickViewTrigger. */
  children: React.ReactNode
  currency?: string
  locale?: string
  /** Return a promise to keep the button pending until it settles. */
  onAddToBag?: (product: ProductCardProduct, colorIndex: number) => void | Promise<void>
  /** Extra content under the price: a description, sizes, a size-guide link. */
  renderDetails?: (product: ProductCardProduct) => React.ReactNode
  detailsLabel?: string
ProductQuickViewTriggerProps
product: ProductCardProduct
  /** Open on this colour — pass the card's selected colour. */
  colorIndex?: number

Source

components/ecomcn/product-quick-view.tsx
"use client"

import * as React from "react"
import { AnimatePresence, MotionConfig, motion, type Transition } from "motion/react"
import { ArrowRight, Check, Eye, X } from "lucide-react"

import { Button } from "@/components/ui/button"
import { PriceTag } from "@/components/ecomcn/price-tag"
import type { ProductCardProduct } from "@/components/ecomcn/product-card"
import { cn } from "@/lib/utils"

/**
 * Quick view with a shared-element morph: the card's image grows into the
 * dialog's image and shrinks back on close, so the shopper never loses track
 * of which product they opened. Motion's `layoutId` does the morph; a native
 * <dialog> does the modal work (top layer, inert page, Escape).
 *
 * Opt-in on purpose — it is the only ecomcn block that depends on `motion`.
 */

// Ease-out, no overshoot: an editorial morph should settle, not bounce.
const MORPH: Transition = { duration: 0.45, ease: [0.2, 0, 0, 1] }

type AddState = "idle" | "pending" | "added"

interface ActiveView {
  product: ProductCardProduct
  colorIndex: number
}

export interface ProductQuickViewContextValue {
  active: ActiveView | null
  open: (
    product: ProductCardProduct,
    options?: { colorIndex?: number; trigger?: HTMLElement | null }
  ) => void
  close: () => void
  /** The shared id that links a card's image to the dialog's. */
  layoutId: (productId: string) => string
}

const ProductQuickViewContext = React.createContext<ProductQuickViewContextValue | null>(null)

export function useProductQuickView() {
  const context = React.useContext(ProductQuickViewContext)
  if (!context) {
    throw new Error("useProductQuickView must be used within <ProductQuickView>.")
  }
  return context
}

/* ─── root ─────────────────────────────────────────────────────────────── */

export interface ProductQuickViewProps {
  /** The listing — cards containing ProductQuickViewImage and ProductQuickViewTrigger. */
  children: React.ReactNode
  currency?: string
  locale?: string
  /** Return a promise to keep the button pending until it settles. */
  onAddToBag?: (product: ProductCardProduct, colorIndex: number) => void | Promise<void>
  /** Extra content under the price: a description, sizes, a size-guide link. */
  renderDetails?: (product: ProductCardProduct) => React.ReactNode
  detailsLabel?: string
}

export function ProductQuickView({
  children,
  currency = "USD",
  locale,
  onAddToBag,
  renderDetails,
  detailsLabel = "View full details",
}: ProductQuickViewProps) {
  const [active, setActive] = React.useState<ActiveView | null>(null)
  const trigger = React.useRef<HTMLElement | null>(null)
  // Namespaces the layout ids, so two quick views on one page never morph
  // into each other's dialogs.
  const scope = React.useId()

  const open = React.useCallback<ProductQuickViewContextValue["open"]>(
    (product, options = {}) => {
      trigger.current =
        options.trigger ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null)
      setActive({ product, colorIndex: options.colorIndex ?? 0 })
    },
    []
  )
  const close = React.useCallback(() => setActive(null), [])
  const layoutId = React.useCallback((id: string) => `${scope}-quick-view-${id}`, [scope])

  const context = React.useMemo(
    () => ({ active, open, close, layoutId }),
    [active, open, close, layoutId]
  )

  return (
    <ProductQuickViewContext.Provider value={context}>
      {children}
      <AnimatePresence>
        {active ? (
          <QuickViewDialog
            key={active.product.id}
            view={active}
            returnFocus={() => {
              if (trigger.current?.isConnected) trigger.current.focus()
            }}
            currency={currency}
            locale={locale}
            onAddToBag={onAddToBag}
            renderDetails={renderDetails}
            detailsLabel={detailsLabel}
          />
        ) : null}
      </AnimatePresence>
    </ProductQuickViewContext.Provider>
  )
}

/* ─── parts used inside a card ─────────────────────────────────────────── */

export function ProductQuickViewImage({
  product,
  className,
  children,
}: {
  product: ProductCardProduct
  className?: string
  /** The card's image — usually <ProductCardImage />. */
  children: React.ReactNode
}) {
  const { layoutId } = useProductQuickView()
  return (
    // reducedMotion="user": with the OS setting on, the dialog simply
    // appears — layout and transform animations are skipped entirely.
    <MotionConfig reducedMotion="user" transition={MORPH}>
      <motion.div
        layoutId={layoutId(product.id)}
        data-slot="product-quick-view-image"
        className={cn("absolute inset-0", className)}
      >
        {children}
      </motion.div>
    </MotionConfig>
  )
}

export interface ProductQuickViewTriggerProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
  product: ProductCardProduct
  /** Open on this colour — pass the card's selected colour. */
  colorIndex?: number
}

export function ProductQuickViewTrigger({
  product,
  colorIndex,
  className,
  children,
  ...props
}: ProductQuickViewTriggerProps) {
  const { open } = useProductQuickView()
  return (
    <button
      type="button"
      aria-haspopup="dialog"
      data-slot="product-quick-view-trigger"
      onClick={(event) => open(product, { colorIndex, trigger: event.currentTarget })}
      className={cn(
        // Sits above the card's ::after link overlay. Revealed on hover and
        // on keyboard focus; always visible where there is no hover at all.
        "absolute top-2 right-2 z-10 inline-flex items-center gap-1.5 bg-background/90 px-2.5 py-1.5 text-[11px] tracking-[0.14em] uppercase opacity-0 transition-opacity outline-none group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none [@media(hover:none)]:opacity-100",
        className
      )}
      {...props}
    >
      {children ?? (
        <>
          <Eye className="size-3.5" aria-hidden />
          Quick view
        </>
      )}
      <span className="sr-only">: {product.name}</span>
    </button>
  )
}

/* ─── the dialog ───────────────────────────────────────────────────────── */

function QuickViewDialog({
  view,
  returnFocus,
  currency,
  locale,
  onAddToBag,
  renderDetails,
  detailsLabel,
}: {
  view: ActiveView
  returnFocus: () => void
  currency: string
  locale?: string
  onAddToBag?: ProductQuickViewProps["onAddToBag"]
  renderDetails?: ProductQuickViewProps["renderDetails"]
  detailsLabel: string
}) {
  const { close, layoutId } = useProductQuickView()
  const { product } = view
  const dialog = React.useRef<HTMLDialogElement>(null)
  const titleId = React.useId()
  const [colorIndex, setColorIndex] = React.useState(view.colorIndex)
  const [addState, setAddState] = React.useState<AddState>("idle")
  const color = product.colors?.[colorIndex]

  React.useEffect(() => {
    const node = dialog.current
    // showModal puts the dialog in the top layer and makes the page inert:
    // focus containment and Escape come from the platform, not from us.
    if (node && !node.open) node.showModal()

    // Lock page scroll, padding by the scrollbar's width so nothing behind
    // the overlay shifts sideways.
    const root = document.documentElement
    const gap = window.innerWidth - root.clientWidth
    const before = { overflow: root.style.overflow, paddingRight: root.style.paddingRight }
    root.style.overflow = "hidden"
    if (gap > 0) root.style.paddingRight = `${gap}px`
    return () => {
      root.style.overflow = before.overflow
      root.style.paddingRight = before.paddingRight
      // This cleanup runs once the exit morph has finished and the dialog has
      // left the DOM. Any earlier and the page is still inert, the focus call
      // is ignored, and the shopper is dropped on <body>.
      returnFocus()
    }
    // Mount and unmount only: the dialog opens once per product.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  const addToBag = async () => {
    if (!onAddToBag || addState === "pending") return
    setAddState("pending")
    try {
      await onAddToBag(product, colorIndex)
      setAddState("added")
    } catch {
      setAddState("idle")
    }
  }

  return (
    <dialog
      ref={dialog}
      aria-labelledby={titleId}
      data-slot="product-quick-view"
      // Escape: play the closing morph instead of vanishing instantly.
      onCancel={(event) => {
        event.preventDefault()
        close()
      }}
      onClose={close}
      className="m-0 h-dvh max-h-none w-full max-w-none bg-transparent p-0 text-foreground backdrop:bg-transparent"
    >
      <div className="fixed inset-0 grid place-items-center overflow-y-auto p-4 sm:p-8">
        <motion.div
          aria-hidden
          className="fixed inset-0 bg-black/45"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.25 }}
          onClick={close}
        />

        <div className="relative grid w-full max-w-4xl md:grid-cols-[1.05fr_0.95fr]">
          {/* The panel's surface fades on its own layer, so the morphing
              image is never inside an element whose opacity is animating. */}
          <motion.div
            aria-hidden
            className="absolute inset-0 bg-background shadow-2xl"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }}
          />

          <button
            type="button"
            onClick={close}
            className="absolute top-3 right-3 z-20 grid size-9 place-items-center bg-background/90 outline-none hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring"
          >
            <X className="size-4" aria-hidden />
            <span className="sr-only">Close</span>
          </button>

          <MotionConfig reducedMotion="user" transition={MORPH}>
            <motion.div
              layoutId={layoutId(product.id)}
              data-slot="product-quick-view-media"
              className="relative aspect-[4/5] overflow-hidden bg-secondary"
            >
              <div className="absolute inset-0">{color?.image ?? product.image}</div>
            </motion.div>
          </MotionConfig>

          <motion.div
            className="relative flex flex-col p-6 sm:p-10"
            initial={{ opacity: 0, y: 8 }}
            animate={{ opacity: 1, y: 0, transition: { delay: 0.18, duration: 0.3 } }}
            exit={{ opacity: 0, transition: { duration: 0.12 } }}
          >
            {product.brand ? (
              <p className="text-[11px] tracking-[0.18em] text-muted-foreground uppercase">
                {product.brand}
              </p>
            ) : null}
            <h2 id={titleId} className="mt-1 text-3xl leading-tight">
              {product.name}
            </h2>
            <PriceTag
              price={product.price}
              compareAt={product.compareAt}
              currency={currency}
              locale={locale}
              className="mt-3"
            />

            {product.colors?.length ? (
              <div className="mt-6">
                <p className="text-xs text-muted-foreground">
                  Colour: <span className="text-foreground">{color?.name}</span>
                </p>
                <div role="group" aria-label="Colour" className="mt-2 flex gap-2">
                  {product.colors.map((c, i) => (
                    <button
                      key={c.name}
                      type="button"
                      aria-label={c.name}
                      aria-pressed={i === colorIndex}
                      onClick={() => {
                        setColorIndex(i)
                        setAddState("idle")
                      }}
                      className={cn(
                        "size-6 border outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                        i === colorIndex
                          ? "border-foreground ring-1 ring-foreground ring-offset-2 ring-offset-background"
                          : "border-foreground/20 hover:border-foreground/50"
                      )}
                      style={{ background: c.hex }}
                    />
                  ))}
                </div>
              </div>
            ) : null}

            {renderDetails ? <div className="mt-6 text-sm">{renderDetails(product)}</div> : null}

            <div className="mt-auto flex flex-col gap-3 pt-8">
              {onAddToBag ? (
                <Button
                  type="button"
                  onClick={addToBag}
                  disabled={addState === "pending"}
                  className="h-11 w-full"
                >
                  {addState === "added" ? (
                    <>
                      <Check className="size-4" aria-hidden /> Added to bag
                    </>
                  ) : addState === "pending" ? (
                    "Adding…"
                  ) : (
                    "Add to bag"
                  )}
                </Button>
              ) : null}
              <a
                href={product.href}
                className="inline-flex items-center gap-1.5 self-start text-sm underline-offset-4 outline-none hover:underline focus-visible:underline"
              >
                {detailsLabel} <ArrowRight className="size-3.5" aria-hidden />
              </a>
              <span className="sr-only" role="status" aria-live="polite">
                {addState === "added" ? `${product.name} added to bag` : ""}
              </span>
            </div>
          </motion.div>
        </div>
      </div>
    </dialog>
  )
}