HextaUI

Add to bag button, bag icon with a live count and a cart drawer or page. Handles optimistic quantities with rollback, undo on remove and free shipping progress.

Open in new tab

Cart is the add-to-bag flow of a store: AddToCartButton on the product page, CartTrigger in the header, and CartDrawer, which slides in from the right on desktop and up from the bottom on phones. CartPanel shows the same cart as a full page.

Every change applies at once. Pressing plus updates the quantity, line total and subtotal straight away, and the request runs behind it. Quick presses are merged into one request for the final quantity. If your callback throws, the line goes back to the last confirmed quantity and shows your message, such as “Only 2 left in Sand / Large”. The same logic is exported as useOptimisticCart for your own layouts.

Removing a line collapses it into a “Removed Ring Vase · Undo” row for five seconds, and the lines below slide up. Undo puts the line back where it was. Lines can also show that their price changed since they were added, or that they sold out, which blocks checkout until they are removed.

The free shipping bar shows how much is left to spend and plays one short moment when the threshold is crossed. AddToCartButton keeps its width while it moves from the label to a spinner (only after 300ms) to a drawn check, and the bag badge rolls only the digit that changed.

  1. HextaUI Pro'yu alın

    Tek bir ödeme, gelecekte eklenecekler dahil tüm blokların kilidini açar. Fiyatları görün.

  2. Pro registry'yi components.json dosyasına ekleyin

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Token'ınızı ekleyin

    Hesap sayfanızda bir token oluşturun ve .env.local dosyasına HEXTAUI_PRO_TOKEN olarak ekleyin.

  4. Bloğu ekleyin

    pnpm dlx shadcn@latest add @hextaui-pro/cart

Shopify Storefront API

Map the Storefront cart to Cart and call cartLinesUpdate, cartLinesRemove and cartLinesAdd from the callbacks. A userErrors message is thrown, so it shows under the line and the quantity rolls back.

"use client"

import * as React from "react"

import type { Cart } from "@/components/blocks/cart/cart"
import { CartDrawer } from "@/components/blocks/cart/cart-drawer"
import { CartTrigger } from "@/components/blocks/cart/cart-trigger"
import { cartQuantity } from "@/components/blocks/cart/cart"

type ShopifyMoney = { amount: string; currencyCode: string }

type ShopifyCart = {
  id: string
  checkoutUrl: string
  cost: { subtotalAmount: ShopifyMoney }
  lines: {
    nodes: {
      id: string
      quantity: number
      cost: { amountPerQuantity: ShopifyMoney; compareAtAmountPerQuantity: ShopifyMoney | null }
      merchandise: {
        id: string
        title: string
        quantityAvailable: number | null
        image: { url: string; altText: string | null } | null
        product: { id: string; title: string; handle: string }
      }
    }[]
  }
  userErrors?: { message: string }[]
}

const CART_FIELDS = `id checkoutUrl cost { subtotalAmount { amount currencyCode } }
  lines(first: 100) { nodes { id quantity
    cost { amountPerQuantity { amount currencyCode } compareAtAmountPerQuantity { amount currencyCode } }
    merchandise { ... on ProductVariant { id title quantityAvailable image { url altText } product { id title handle } } } } }`

async function storefront<T>(query: string, variables: Record<string, unknown>) {
  const response = await fetch(
    `https://${process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN}/api/2025-10/graphql.json`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Shopify-Storefront-Access-Token": process.env.NEXT_PUBLIC_SHOPIFY_TOKEN ?? "",
      },
      body: JSON.stringify({ query, variables }),
    }
  )
  const { data } = (await response.json()) as { data: T }
  return data
}

function fromShopify(cart: ShopifyCart): Cart {
  return {
    id: cart.id,
    currencyCode: cart.cost.subtotalAmount.currencyCode,
    lines: cart.lines.nodes.map((line) => ({
      id: line.id,
      quantity: line.quantity,
      price: line.cost.amountPerQuantity,
      compareAtPrice: line.cost.compareAtAmountPerQuantity,
      maxQuantity: line.merchandise.quantityAvailable ?? undefined,
      merchandise: {
        id: line.merchandise.id,
        productId: line.merchandise.product.id,
        productTitle: line.merchandise.product.title,
        variantTitle: line.merchandise.title === "Default Title" ? undefined : line.merchandise.title,
        href: `/products/${line.merchandise.product.handle}`,
        image: line.merchandise.image
          ? { id: line.merchandise.image.url, src: line.merchandise.image.url, alt: line.merchandise.image.altText ?? "" }
          : undefined,
      },
    })),
  }
}

async function mutate(name: string, query: string, variables: Record<string, unknown>) {
  const data = await storefront<Record<string, { cart: ShopifyCart; userErrors: { message: string }[] }>>(
    query,
    variables
  )
  const { cart, userErrors } = data[name]
  if (userErrors.length > 0) throw new Error(userErrors[0].message)
  return cart
}

export function ShopifyCart({ initial }: { initial: ShopifyCart }) {
  const [shopify, setShopify] = React.useState(initial)
  const cart = React.useMemo(() => fromShopify(shopify), [shopify])

  const apply = (next: ShopifyCart) => {
    setShopify(next)
    return fromShopify(next)
  }

  return (
    <CartDrawer
      cart={cart}
      trigger={<CartTrigger count={cartQuantity(cart)} />}
      freeShippingThreshold={{ amount: "150.00", currencyCode: cart.currencyCode }}
      onUpdateLine={async (id, quantity) =>
        apply(
          await mutate(
            "cartLinesUpdate",
            `mutation($cartId: ID!, $lines: [CartLineUpdateInput!]!) { cartLinesUpdate(cartId: $cartId, lines: $lines) { cart { ${CART_FIELDS} } userErrors { message } } }`,
            { cartId: shopify.id, lines: [{ id, quantity }] }
          )
        )
      }
      onRemoveLine={async (id) =>
        apply(
          await mutate(
            "cartLinesRemove",
            `mutation($cartId: ID!, $lineIds: [ID!]!) { cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { cart { ${CART_FIELDS} } userErrors { message } } }`,
            { cartId: shopify.id, lineIds: [id] }
          )
        )
      }
      onUndoRemove={async (line) =>
        apply(
          await mutate(
            "cartLinesAdd",
            `mutation($cartId: ID!, $lines: [CartLineInput!]!) { cartLinesAdd(cartId: $cartId, lines: $lines) { cart { ${CART_FIELDS} } userErrors { message } } }`,
            { cartId: shopify.id, lines: [{ merchandiseId: line.merchandise.id, quantity: line.quantity }] }
          )
        )
      }
      onCheckout={() => {
        window.location.href = shopify.checkoutUrl
      }}
    />
  )
}

Next.js server actions

Pass server actions straight to the callbacks and keep the returned cart in state. The bag button in the header and the add button on the product page share that state.

"use client"

import * as React from "react"

import type { Cart, CartLine } from "@/components/blocks/cart/cart"
import { AddToCartButton } from "@/components/blocks/cart/add-to-cart-button"
import { CartDrawer } from "@/components/blocks/cart/cart-drawer"
import { CartTrigger } from "@/components/blocks/cart/cart-trigger"
import { cartQuantity } from "@/components/blocks/cart/cart"

type CartActions = {
  addToCart: (variantId: string, quantity: number) => Promise<Cart>
  updateLine: (lineId: string, quantity: number) => Promise<Cart>
  removeLine: (lineId: string) => Promise<Cart>
  restoreLine: (line: CartLine) => Promise<Cart>
  startCheckout: () => Promise<string>
}

export function StoreHeaderCart({
  initialCart,
  actions,
  product,
}: {
  initialCart: Cart
  actions: CartActions
  product: { title: string; variantId: string; variantTitle?: string }
}) {
  const [cart, setCart] = React.useState(initialCart)
  const [open, setOpen] = React.useState(false)

  const run = async (action: Promise<Cart>) => {
    const next = await action
    setCart(next)
    return next
  }

  return (
    <>
      <CartDrawer
        cart={cart}
        open={open}
        onOpenChange={setOpen}
        trigger={<CartTrigger count={cartQuantity(cart)} />}
        onUpdateLine={(id, quantity) => run(actions.updateLine(id, quantity))}
        onRemoveLine={(id) => run(actions.removeLine(id))}
        onUndoRemove={(line) => run(actions.restoreLine(line))}
        onCheckout={async () => {
          window.location.assign(await actions.startCheckout())
        }}
      />
      <AddToCartButton
        size="lg"
        item={{ title: product.title, variantTitle: product.variantTitle }}
        onAdd={() => run(actions.addToCart(product.variantId, 1))}
      />
    </>
  )
}

Full cart page

CartPanel lays the same lines out as a page with a sticky summary, for stores where the bag icon links to /cart instead of opening a drawer.

"use client"

import * as React from "react"
import { useRouter } from "next/navigation"

import { money } from "../price/money"
import type { Cart } from "@/components/blocks/cart/cart"
import { CartPanel, type CartRecommendation } from "@/components/blocks/cart/cart-drawer"

export function CartPage({
  initialCart,
  recommendations,
}: {
  initialCart: Cart
  recommendations: CartRecommendation[]
}) {
  const router = useRouter()
  const [cart, setCart] = React.useState(initialCart)

  const send = async (path: string, body: unknown) => {
    const response = await fetch(path, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    })
    const json = (await response.json()) as { cart?: Cart; error?: string }
    if (!response.ok || !json.cart) throw new Error(json.error ?? "Something went wrong")
    setCart(json.cart)
    return json.cart
  }

  return (
    <main className="mx-auto w-full max-w-5xl px-4 py-10 sm:px-8">
      <CartPanel
        cart={cart}
        freeShippingThreshold={money(150, cart.currencyCode)}
        recommendations={recommendations}
        onUpdateLine={(lineId, quantity) => send("/api/cart/update", { lineId, quantity })}
        onRemoveLine={(lineId) => send("/api/cart/remove", { lineId })}
        onUndoRemove={(line, index) => send("/api/cart/restore", { line, index })}
        onAddLine={(line) => send("/api/cart/add", { merchandiseId: line.merchandise.id, quantity: 1 })}
        onCheckout={() => router.push("/checkout")}
        onContinueShopping={() => router.push("/shop")}
      />
    </main>
  )
}

Yapı

Dıştan içe doğru birleştirdiğiniz parçalar.

ParçaAçıklama
AddToCartButtonThe add button with its pending, added and error states.
CartTriggerThe bag icon button with a rolling count badge.
CartDrawerThe cart in a sheet: free shipping, lines, recommendations and summary.
CartPanelThe same cart laid out as a page with a sticky summary.
QuantityStepperMinus, typed quantity and plus. Minus turns into remove at one.
FreeShippingProgressThe amount left to free shipping and its progress bar.
useOptimisticCartOptimistic quantities, rollback, removal with undo and announcements, without the UI.
lineFromVariant, setLineQuantity, removeLine, restoreLine, getCartCostCart helpers shared with the checkout blocks.
PropTürVarsayılan
cartThe cart from your server. Lines, prices and discounts as Money.
Cart–
onUpdateLineCalled after a stepper change. The new quantity shows at once; throw to roll it back with your message under the line. Rapid changes are coalesced to the latest quantity.
(lineId: string, quantity: number) => Promise<Cart | void>–
onRemoveLineCalled when a line is removed. The line collapses at once and comes back with an error if this throws.
(lineId: string) => Promise<Cart | void>–
onUndoRemoveRe-adds a removed line. When set, a removed line leaves a “Removed · Undo” row for undoTimeout.
(line: CartLine, index: number) => Promise<Cart | void>–
onAddLineAdds a recommended product. Its line appears at once as pending.
(line: CartLine) => Promise<Cart | void>–
onCheckoutCalled by Checkout after every pending change has synced. The button shows a spinner while it runs.
(cart: Cart) => unknown–
onContinueShoppingCalled by Continue shopping. The drawer also closes.
() => void–
freeShippingThresholdShows the free shipping bar and the amount left to reach it.
Money | null–
recommendationsProducts for the empty state and the “You might also like” row. Items already in the cart are hidden.
CartRecommendation[]–
upsellsShow the recommendation row when the cart has items.
booleantrue
undoTimeoutHow long the undo row stays, in ms. Hovering or focusing it pauses the timer.
number5000
localeLocale for money and numbers.
Intl.LocalesArgument"en-US"
trailingZerosSet false to show $38 instead of $38.00 for whole amounts.
booleantrue
labelsEvery user-facing string, including announcements.
Partial<CartLabels>–
openControlled open state.
boolean–
defaultOpenInitial open state when uncontrolled.
booleanfalse
onOpenChangeCalled when the drawer opens or closes.
(open: boolean) => void–
triggerAn element that opens the drawer, usually CartTrigger. Focus returns to it on close.
React.ReactElement–
sideWhere the drawer comes from. auto uses the bottom on screens under 640px.
"auto" | "right" | "bottom""auto"

CartPanel

Also takes section props.

PropTürVarsayılan
cartThe cart from your server. Lines, prices and discounts as Money.
Cart–
onUpdateLineCalled after a stepper change. The new quantity shows at once; throw to roll it back with your message under the line. Rapid changes are coalesced to the latest quantity.
(lineId: string, quantity: number) => Promise<Cart | void>–
onRemoveLineCalled when a line is removed. The line collapses at once and comes back with an error if this throws.
(lineId: string) => Promise<Cart | void>–
onUndoRemoveRe-adds a removed line. When set, a removed line leaves a “Removed · Undo” row for undoTimeout.
(line: CartLine, index: number) => Promise<Cart | void>–
onAddLineAdds a recommended product. Its line appears at once as pending.
(line: CartLine) => Promise<Cart | void>–
onCheckoutCalled by Checkout after every pending change has synced. The button shows a spinner while it runs.
(cart: Cart) => unknown–
onContinueShoppingCalled by Continue shopping. The drawer also closes.
() => void–
freeShippingThresholdShows the free shipping bar and the amount left to reach it.
Money | null–
recommendationsProducts for the empty state and the “You might also like” row. Items already in the cart are hidden.
CartRecommendation[]–
upsellsShow the recommendation row when the cart has items.
booleantrue
undoTimeoutHow long the undo row stays, in ms. Hovering or focusing it pauses the timer.
number5000
localeLocale for money and numbers.
Intl.LocalesArgument"en-US"
trailingZerosSet false to show $38 instead of $38.00 for whole amounts.
booleantrue
labelsEvery user-facing string, including announcements.
Partial<CartLabels>–
headingThe page heading.
React.ReactNode"Bag"

AddToCartButton

Also takes Button props such as size, variant and disabled.

PropTürVarsayılan
onAddAdds the item. Return a promise to show progress, throw to show the error under the button, or return false to cancel quietly, for example when a size is missing.
() => unknown–
itemUsed for the announcement “Added Ring Vase, Sand / Small to your bag”.
{ title: string; variantTitle?: string }–
childrenThe idle label.
React.ReactNode"Add to bag"
spinnerDelayHow long to wait before showing the spinner, in ms.
number300
successDurationHow long “Added” stays, in ms.
number1200
labelsThe added label, error fallback and announcement.
Partial<AddToCartLabels>–

CartTrigger

Also takes Button props.

PropTürVarsayılan
countItems in the cart. The badge appears at one and hides at zero.
number–
maxCounts above this show as 99+.
number99
labelsThe accessible name, “Bag, 3 items” by default.
Partial<CartTriggerLabels>–

QuantityStepper

Also takes div props.

PropTürVarsayılan
valueThe current quantity.
number–
onValueChangeCalled right away for plus and minus, and 250ms after typing stops.
(value: number) => void–
onRemoveCalled by minus at the minimum or a typed 0.
() => void–
minThe lowest quantity.
number1
maxThe highest quantity. Plus is disabled there.
number–
titleThe item name used in button labels.
string–
debounceTyping delay before a typed value is applied, in ms.
number250

FreeShippingProgress

Also takes div props.

PropTürVarsayılan
subtotalThe current subtotal.
Money–
thresholdThe free shipping threshold.
Money–
celebratedSet true to skip the unlock moment, for example after it played once.
boolean–
onUnlockCalled when the subtotal first crosses the threshold.
() => void–

useOptimisticCart

useOptimisticCart(cart, handlers, { labels, locale }) returns the optimistic cart and its actions.

PropTürVarsayılan
cartThe optimistic cart to render.
Cart–
rowsLines and removed rows in display order.
CartRow[]–
updateLine, removeLine, undoRemove, addLineActions that update the view at once and call your handlers.
functions–
syncingTrue while any change is waiting for the server.
boolean–
announcementText for a polite live region.
string–
flushResolves once every pending change has settled.
() => Promise<void>–
TuşAction
EnterSpaceOn Add to bag, adds the item. Presses while it’s adding are ignored.
ArrowUpArrowDownIn the quantity field, changes the quantity.
EnterIn the quantity field, applies a typed value right away.
EscapeCloses the drawer and returns focus to the bag button.
  • The bag button is named with its count, “Bag, 3 items”. Changes are announced in one polite live region: “Removed Ring Vase. Undo available.”, the new quantity with the subtotal, and unlocking free shipping.
  • Each stepper is a labelled group with “Decrease quantity of Ring Vase”, “Increase quantity of Ring Vase” and a numeric text field that selects its value on focus. Failed updates are announced with role alert.
  • After a removal, focus moves to Undo. After undo, it returns to the restored line. When an undo row expires with focus in it, focus moves to the next line.
  • The free shipping bar is a progressbar with a value such as “$134.00 of $150.00 toward free shipping”. All motion has a reduced-motion version that only fades.

Şunlarla geliştirildi

Cart bloğunun yapıldığı ücretsiz HextaUI bileşenleri. Her biri tek başına yüklenir.

Kod

10 dosya, components/blocks/cart konumuna eklendi.