Cart
The Cart component displays the items in the user's cart, along with a summary of the total cost.
- Displays the items in the user's cart
- Displays a summary of the total cost
- Allows the user to remove items from the cart
- Allows the user to update the quantity of items in the cart
Installation
Add the following Soul components
The cart component uses the streamable, button-link, skeleton, section-layout, sticky-sidebar-layout, button, toaster, input, field-error and chip components. Make sure you have added them to your project.
Install the following dependencies
npm install @conform-to/react @conform-to/zod clsx lucide-react zod
Copy and paste the following code into your project
sections/cart/index.tsx
import { Stream, Streamable } from '@/vibes/soul/lib/streamable';import { ButtonLink } from '@/vibes/soul/primitives/button-link';import * as Skeleton from '@/vibes/soul/primitives/skeleton';import { SectionLayout } from '@/vibes/soul/sections/section-layout';import { StickySidebarLayout } from '@/vibes/soul/sections/sticky-sidebar-layout';import { CartClient, Cart as CartData, CartLineItem, CartProps } from './client';export { type CartLineItem } from './client';export function Cart<LineItem extends CartLineItem>({ cart: streamableCart, decrementLineItemLabel: streamableDecrementLineItemLabel, title = 'Cart', summaryTitle = 'Summary', ...props}: Omit<CartProps<LineItem>, 'cart'> & { cart: Streamable<CartData<LineItem>>;}) { return ( <Stream fallback={<CartSkeleton summaryTitle={summaryTitle} title={title} />} value={streamableCart} > {(cart) => <CartClient {...props} cart={cart} summaryTitle={summaryTitle} title={title} />} </Stream> );}export interface CartSkeletonProps { className?: string; placeholderCount?: number; summaryPlaceholderCount?: number; title?: string; summaryTitle?: string;}export function CartSkeleton({ title = 'Cart', summaryTitle = 'Summary', placeholderCount = 2, summaryPlaceholderCount = 3,}: CartSkeletonProps) { return ( <StickySidebarLayout className="group/cart text-[var(--cart-text,hsl(var(--foreground)))]" sidebar={ <div> <h2 className="mb-10 font-[family-name:var(--cart-title-font-family,var(--font-family-heading))] text-4xl font-medium leading-none @xl:text-5xl"> {summaryTitle} </h2> <div className="group-has-[[data-pending]]/cart:animate-pulse"> <div className="w-full" data-pending> <div className="divide-y divide-[var(--skeleton,hsl(var(--contrast-300)/15%))]"> {Array.from({ length: summaryPlaceholderCount }).map((_, index) => ( <div className="py-4" key={index}> <div className="flex items-center justify-between"> <Skeleton.Text characterCount={10} className="rounded-md" /> <Skeleton.Text characterCount={8} className="rounded-md" /> </div> </div> ))} </div> <div className="flex justify-between border-t border-[var(--skeleton,hsl(var(--contrast-300)/15%))] py-6 text-xl font-bold"> <div className="flex items-center justify-between"> <Skeleton.Text characterCount={8} className="rounded-md" /> </div> <div className="flex items-center justify-between"> <Skeleton.Text characterCount={8} className="rounded-md" /> </div> </div> </div> </div> <Skeleton.Box className="mt-4 h-[58px] w-full rounded-full" /> </div> } sidebarPosition="after" sidebarSize="1/3" > <div> <h1 className="mb-10 font-[family-name:var(--cart-title-font-family,var(--font-family-heading))] text-4xl font-medium leading-none @xl:text-5xl"> {title} </h1> {/* Cart Line Items */} <div className="group-has-[[data-pending]]/cart:animate-pulse"> <ul className="flex flex-col gap-5" data-pending> {Array.from({ length: placeholderCount }).map((_, index) => ( <li className="flex flex-col items-start gap-x-5 gap-y-4 @container @sm:flex-row" key={index} > {/* Image */} <Skeleton.Box className="aspect-square w-full max-w-24 rounded-xl" /> <div className="flex flex-grow flex-col flex-wrap justify-between gap-y-2 @xl:flex-row"> <div className="flex w-full flex-1 flex-col @xl:w-1/2 @xl:pr-4"> {/* Line Item Title */} <Skeleton.Text characterCount={15} className="rounded-md" /> {/* Subtitle */} <Skeleton.Text characterCount={10} className="rounded-md" /> </div> {/* Counter */} <div> <div className="flex w-full flex-wrap items-center gap-x-5 gap-y-2"> {/* Price */} <Skeleton.Text characterCount={5} className="rounded-md" /> {/* Counter */} <Skeleton.Box className="h-[44px] w-[118px] rounded-lg" /> {/* DeleteLineItemButton */} <Skeleton.Box className="-ml-1 h-8 w-8 rounded-full" /> </div> </div> </div> </li> ))} </ul> </div> </div> </StickySidebarLayout> );}export interface CartEmptyState { title: string; subtitle: string; cta: { label: string; href: string; };}export function CartEmptyState({ title, subtitle, cta }: CartEmptyState) { return ( <SectionLayout className="text-center font-[family-name:var(--cart-font-family,var(--font-family-body))]"> <h1 className="mb-3 text-center font-[family-name:var(--cart-title-font-family,var(--font-family-heading))] text-3xl leading-none text-[var(--cart-title,hsl(var(--foreground)))] @xl:text-4xl"> {title} </h1> <p className="leading-normaltext-[var(--cart-subtitle,hsl(var(--contrast-500)))] mb-6 text-center @3xl:text-lg"> {subtitle} </p> <ButtonLink href={cta.href}>{cta.label}</ButtonLink> </SectionLayout> );}
sections/cart/client.tsx
'use client';import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react';import { parseWithZod } from '@conform-to/zod';import { clsx } from 'clsx';import { ArrowRight, Minus, Plus, Trash2 } from 'lucide-react';import Image from 'next/image';import { ComponentPropsWithoutRef, startTransition, useActionState, useEffect, useOptimistic,} from 'react';import { useFormStatus } from 'react-dom';import { Button } from '@/vibes/soul/primitives/button';import { toast } from '@/vibes/soul/primitives/toaster';import { StickySidebarLayout } from '@/vibes/soul/sections/sticky-sidebar-layout';import { CouponCodeForm, CouponCodeFormState } from './coupon-code-form';import { cartLineItemActionFormDataSchema } from './schema';import { CartEmptyState } from '.';type Action<State, Payload> = (state: Awaited<State>, payload: Payload) => State | Promise<State>;export interface CartLineItem { id: string; image: { alt: string; src: string }; title: string; subtitle: string; quantity: number; price: string;}export interface CartSummaryItem { label: string; value: string;}export interface CartState<LineItem extends CartLineItem> { lineItems: LineItem[]; lastResult: SubmissionResult | null;}export interface Cart<LineItem extends CartLineItem> { lineItems: LineItem[]; summaryItems: CartSummaryItem[]; total: string; totalLabel?: string;}interface CouponCode { action: Action<CouponCodeFormState, FormData>; couponCodes?: string[]; ctaLabel?: string; disabled?: boolean; label?: string; placeholder?: string; removeLabel?: string;}export interface CartProps<LineItem extends CartLineItem> { title?: string; summaryTitle?: string; emptyState?: CartEmptyState; lineItemAction: Action<CartState<LineItem>, FormData>; checkoutAction: Action<SubmissionResult | null, FormData>; checkoutLabel?: string; deleteLineItemLabel?: string; decrementLineItemLabel?: string; incrementLineItemLabel?: string; cart: Cart<LineItem>; couponCode?: CouponCode;}const defaultEmptyState = { title: 'Your cart is empty', subtitle: 'Add some products to get started.', cta: { label: 'Continue shopping', href: '#' },};/** * This component supports various CSS variables for theming. Here's a comprehensive list, along * with their default values: * * ```css * :root { * --cart-focus: hsl(var(--primary)); * --cart-font-family: var(--font-family-body); * --cart-title-font-family: var(--font-family-heading); * --cart-text: hsl(var(--foreground)); * --cart-subtitle-text: hsl(var(--contrast-500)); * --cart-subtext-text: hsl(var(--contrast-300)); * --cart-icon: hsl(var(--contrast-300)); * --cart-icon-hover: hsl(var(--foreground)); * --cart-border: hsl(var(--contrast-100)); * --cart-image-background: hsl(var(--contrast-100)); * --cart-button-background: hsl(var(--contrast-100)); * --cart-counter-icon: hsl(var(--contrast-300)); * --cart-counter-icon-hover: hsl(var(--foreground)); * --cart-counter-background: hsl(var(--background)); * --cart-counter-background-hover: hsl(var(--contast-100) / 50%); * } * ``` */export function CartClient<LineItem extends CartLineItem>({ title, cart, couponCode, decrementLineItemLabel, incrementLineItemLabel, deleteLineItemLabel, lineItemAction, checkoutAction, checkoutLabel = 'Checkout', emptyState = defaultEmptyState, summaryTitle,}: CartProps<LineItem>) { const [state, formAction] = useActionState(lineItemAction, { lineItems: cart.lineItems, lastResult: null, }); const [form] = useForm({ lastResult: state.lastResult }); useEffect(() => { if (form.errors) { form.errors.forEach((error) => { toast.error(error); }); } }, [form.errors]); const [optimisticLineItems, setOptimisticLineItems] = useOptimistic<CartLineItem[], FormData>( state.lineItems, (prevState, formData) => { const submission = parseWithZod(formData, { schema: cartLineItemActionFormDataSchema }); if (submission.status !== 'success') return prevState; switch (submission.value.intent) { case 'increment': { const { id } = submission.value; return prevState.map((item) => item.id === id ? { ...item, quantity: item.quantity + 1 } : item, ); } case 'decrement': { const { id } = submission.value; return prevState.map((item) => item.id === id ? { ...item, quantity: item.quantity - 1 } : item, ); } case 'delete': { const { id } = submission.value; return prevState.filter((item) => item.id !== id); } default: return prevState; } }, ); const optimisticQuantity = optimisticLineItems.reduce((total, item) => total + item.quantity, 0); if (optimisticQuantity === 0) { return <CartEmptyState {...emptyState} />; } return ( <StickySidebarLayout className="font-[family-name:var(--cart-font-family,var(--font-family-body))] text-[var(--cart-text,hsl(var(--foreground)))]" sidebar={ <div> <h2 className="mb-10 font-[family-name:var(--cart-title-font-family,var(--font-family-heading))] text-4xl font-medium leading-none @xl:text-5xl"> {summaryTitle} </h2> <dl aria-label="Receipt Summary" className="w-full"> <div className="divide-y divide-[var(--cart-border,hsl(var(--contrast-100)))]"> {cart.summaryItems.map((summaryItem, index) => ( <div className="flex justify-between py-4" key={index}> <dt>{summaryItem.label}</dt> <dd>{summaryItem.value}</dd> </div> ))} </div> {couponCode && ( <CouponCodeForm action={couponCode.action} couponCodes={couponCode.couponCodes} ctaLabel={couponCode.ctaLabel} disabled={couponCode.disabled} label={couponCode.label} placeholder={couponCode.placeholder} removeLabel={couponCode.removeLabel} /> )} <div className="flex justify-between border-t border-[var(--cart-border,hsl(var(--contrast-100)))] py-6 text-xl font-bold"> <dt>{cart.totalLabel ?? 'Total'}</dt> <dl>{cart.total}</dl> </div> </dl> <CheckoutButton action={checkoutAction} className="mt-4 w-full"> {checkoutLabel} <ArrowRight size={20} strokeWidth={1} /> </CheckoutButton> </div> } sidebarPosition="after" sidebarSize="1/3" > <div className="w-full"> <h1 className="mb-10 font-[family-name:var(--cart-title-font-family,var(--font-family-heading))] text-4xl font-medium leading-none @xl:text-5xl"> {title} <span className="ml-4 text-[var(--cart-subtext-text,hsl(var(--contrast-300)))] contrast-more:text-[var(--cart-subtitle-text,hsl(var(--contrast-500)))]"> {optimisticQuantity} </span> </h1> {/* Cart Items */} <ul className="flex flex-col gap-5"> {optimisticLineItems.map((lineItem) => ( <li className="flex flex-col items-start gap-x-5 gap-y-4 @container @sm:flex-row" key={lineItem.id} > <div className="relative aspect-square w-full max-w-24 overflow-hidden rounded-xl bg-[var(--cart-image-background,hsl(var(--contrast-100)))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))] focus-visible:ring-offset-4"> <Image alt={lineItem.image.alt} className="object-cover" fill sizes="(min-width: 28rem) 9rem, (min-width: 24rem) 6rem, 100vw" src={lineItem.image.src} /> </div> <div className="flex flex-grow flex-col flex-wrap justify-between gap-y-2 @xl:flex-row"> <div className="flex w-full flex-1 flex-col @xl:w-1/2 @xl:pr-4"> <span className="font-medium">{lineItem.title}</span> <span className="text-[var(--cart-subtext-text,hsl(var(--contrast-300)))] contrast-more:text-[var(--cart-subtitle-text,hsl(var(--contrast-500)))]"> {lineItem.subtitle} </span> </div> <CounterForm action={formAction} decrementLabel={decrementLineItemLabel} deleteLabel={deleteLineItemLabel} incrementLabel={incrementLineItemLabel} lineItem={lineItem} onSubmit={(formData) => { startTransition(() => { formAction(formData); setOptimisticLineItems(formData); }); }} /> </div> </li> ))} </ul> </div> </StickySidebarLayout> );}function CounterForm({ lineItem, action, onSubmit, incrementLabel = 'Increase count', decrementLabel = 'Decrease count', deleteLabel = 'Remove item',}: { lineItem: CartLineItem; incrementLabel?: string; decrementLabel?: string; deleteLabel?: string; action: (payload: FormData) => void; onSubmit: (formData: FormData) => void;}) { const [form, fields] = useForm({ defaultValue: { id: lineItem.id }, shouldValidate: 'onBlur', shouldRevalidate: 'onInput', onValidate({ formData }) { return parseWithZod(formData, { schema: cartLineItemActionFormDataSchema }); }, onSubmit(event, { formData }) { event.preventDefault(); onSubmit(formData); }, }); return ( <form {...getFormProps(form)} action={action}> <input {...getInputProps(fields.id, { type: 'hidden' })} key={fields.id.id} /> <div className="flex w-full flex-wrap items-center gap-x-5 gap-y-2"> <span className="font-medium @xl:ml-auto">{lineItem.price}</span> {/* Counter */} <div className="flex items-center rounded-lg border border-[var(--cart-counter-border,hsl(var(--contrast-100)))]"> <button aria-label={decrementLabel} className={clsx( 'group rounded-l-lg bg-[var(--cart-counter-background,hsl(var(--background)))] p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))] disabled:cursor-not-allowed', lineItem.quantity === 1 ? 'opacity-50' : 'hover:bg-[var(--cart-counter-background-hover,hsl(var(--contrast-100)/50%))]', )} disabled={lineItem.quantity === 1} name="intent" type="submit" value="decrement" > <Minus className={clsx( 'text-[var(--cart-counter-icon,hsl(var(--contrast-300)))] transition-colors duration-300', lineItem.quantity !== 1 && 'group-hover:text-[var(--cart-counter-icon-hover,hsl(var(--foreground)))]', )} size={18} strokeWidth={1.5} /> </button> <span className="flex w-8 select-none justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))]"> {lineItem.quantity} </span> <button aria-label={incrementLabel} className={clsx( 'group rounded-r-lg bg-[var(--cart-counter-background,hsl(var(--background)))] p-3 transition-colors duration-300 hover:bg-[var(--cart-counter-background-hover,hsl(var(--contrast-100)/50%))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))] disabled:cursor-not-allowed', )} name="intent" type="submit" value="increment" > <Plus className="text-[var(--cart-counter-icon,hsl(var(--contrast-300)))] transition-colors duration-300 group-hover:text-[var(--cart-counter-icon-hover,hsl(var(--foreground)))]" size={18} strokeWidth={1.5} /> </button> </div> <button aria-label={deleteLabel} className="group -ml-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors duration-300 hover:bg-[var(--cart-button-background,hsl(var(--contrast-100)))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cart-focus,hsl(var(--primary)))] focus-visible:ring-offset-4" name="intent" type="submit" value="delete" > <Trash2 className="text-[var(--cart-icon,hsl(var(--contrast-300)))] group-hover:text-[var(--cart-icon-hover,hsl(var(--foreground)))]" size={20} strokeWidth={1} /> </button> </div> </form> );}function CheckoutButton({ action, ...props}: { action: Action<SubmissionResult | null, FormData> } & ComponentPropsWithoutRef< typeof Button>) { const [lastResult, formAction] = useActionState(action, null); const [form] = useForm({ lastResult }); useEffect(() => { if (form.errors) { form.errors.forEach((error) => { toast.error(error); }); } }, [form.errors]); return ( <form action={formAction}> <SubmitButton {...props} /> </form> );}function SubmitButton(props: ComponentPropsWithoutRef<typeof Button>) { const { pending } = useFormStatus(); return <Button {...props} disabled={pending} loading={pending} type="submit" />;}
sections/cart/schema.ts
import { z } from 'zod';export const cartLineItemActionFormDataSchema = z.discriminatedUnion('intent', [ z.object({ intent: z.literal('increment'), id: z.string(), }), z.object({ intent: z.literal('decrement'), id: z.string(), }), z.object({ intent: z.literal('delete'), id: z.string(), }),]);export const couponCodeActionFormDataSchema = ({ required_error = 'Please enter a valid promo code',}: { required_error?: string;}) => z.discriminatedUnion('intent', [ z.object({ intent: z.literal('apply'), couponCode: z.string({ required_error }), }), z.object({ intent: z.literal('delete'), couponCode: z.string(), }), ]);
sections/cart/coupon-code-form/index.tsx
'use client';import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react';import { parseWithZod } from '@conform-to/zod';import { startTransition, useActionState, useOptimistic } from 'react';import { useFormStatus } from 'react-dom';import { FieldError } from '@/vibes/soul/form/field-error';import { Input } from '@/vibes/soul/form/input';import { Button } from '@/vibes/soul/primitives/button';import { couponCodeActionFormDataSchema } from '../schema';import { CouponChip } from './coupon-chip';type Action<State, Payload> = (state: Awaited<State>, payload: Payload) => State | Promise<State>;export interface CouponCodeFormState { couponCodes: string[]; lastResult: SubmissionResult | null;}export interface CouponCodeFormProps { action: Action<CouponCodeFormState, FormData>; couponCodes?: string[]; ctaLabel?: string; disabled?: boolean; label?: string; placeholder?: string; removeLabel?: string; requiredErrorMessage?: string;}export function CouponCodeForm({ action, couponCodes, ctaLabel = 'Apply', disabled = false, label = 'Promo code', placeholder, removeLabel, requiredErrorMessage,}: CouponCodeFormProps) { const [state, formAction] = useActionState(action, { couponCodes: couponCodes ?? [], lastResult: null, }); const [optimisticCouponCodes, setOptimisticCouponCodes] = useOptimistic<string[], FormData>( state.couponCodes, (prevState, formData) => { const submission = parseWithZod(formData, { schema: couponCodeActionFormDataSchema({ required_error: requiredErrorMessage }), }); if (submission.status !== 'success') return prevState; switch (submission.value.intent) { case 'delete': { const couponCode = submission.value.couponCode; return prevState.filter((code) => code !== couponCode); } default: return prevState; } }, ); const [form, fields] = useForm({ lastResult: state.lastResult, shouldValidate: 'onBlur', shouldRevalidate: 'onInput', onValidate({ formData }) { return parseWithZod(formData, { schema: couponCodeActionFormDataSchema({ required_error: requiredErrorMessage }), }); }, onSubmit(event, { formData }) { event.preventDefault(); startTransition(() => { formAction(formData); setOptimisticCouponCodes(formData); }); }, }); return ( <div className="border-[var(--cart-border: var(--contrast-100),hsl(var(--contrast-100)))] space-y-2 border-t pb-5 pt-4"> <form {...getFormProps(form)} action={formAction} className="space-y-2"> <label htmlFor={fields.couponCode.id}>{label}</label> <div className="flex gap-1.5"> <Input {...getInputProps(fields.couponCode, { required: true, type: 'text', })} disabled={disabled} errors={fields.couponCode.errors} id={fields.couponCode.id} key={fields.couponCode.id} placeholder={placeholder} /> <SubmitButton disabled={disabled}>{ctaLabel}</SubmitButton> </div> </form> {optimisticCouponCodes.length > 0 && ( <div className="flex flex-wrap gap-1.5"> {optimisticCouponCodes.map((couponCode) => ( <CouponChip action={formAction} couponCode={couponCode} key={couponCode} onSubmit={(formData) => { startTransition(() => { formAction(formData); setOptimisticCouponCodes(formData); }); }} removeLabel={removeLabel} /> ))} </div> )} {form.errors?.map((error, index) => <FieldError key={index}>{error}</FieldError>)} </div> );}function SubmitButton({ disabled, ...props }: React.ComponentPropsWithoutRef<typeof Button>) { const { pending } = useFormStatus(); return ( <Button {...props} className="shrink-0" disabled={disabled ?? pending} loading={pending} name="intent" size="small" type="submit" value="apply" variant="secondary" /> );}
sections/cart/coupon-code-form/coupon-chip.tsx
import { getFormProps, getInputProps, useForm } from '@conform-to/react';import { parseWithZod } from '@conform-to/zod';import { Chip } from '@/vibes/soul/primitives/chip';import { couponCodeActionFormDataSchema } from '../schema';export interface CouponChipProps { action: (payload: FormData) => void; onSubmit: (formData: FormData) => void; couponCode: string; removeLabel?: string;}export function CouponChip({ couponCode, removeLabel = 'Remove promo code', onSubmit, action,}: CouponChipProps) { const [form, fields] = useForm({ onValidate({ formData }) { return parseWithZod(formData, { schema: couponCodeActionFormDataSchema({}), }); }, onSubmit(event, { formData }) { event.preventDefault(); onSubmit(formData); }, }); return ( <form {...getFormProps(form)} action={action}> <input {...getInputProps(fields.couponCode, { type: 'hidden', })} value={couponCode} /> <Chip name="intent" removeLabel={removeLabel} value="delete"> {couponCode.toUpperCase()} </Chip> </form> );}
Usage
Refer to the example above for a complete reference on how to use the Cart component.
API Reference
CartProps
Name | Type | Default |
---|---|---|
title | string | |
summaryTitle | string | |
emptyState | CartEmptyState | |
lineItemAction* | Action<CartState<LineItem>, FormData> | |
checkoutAction* | Action<SubmissionResult | null, FormData> | |
checkoutLabel | string | |
deleteLineItemLabel | string | |
decrementLineItemLabel | string | |
incrementLineItemLabel | string | |
cart* | Cart<LineItem> | |
couponCode | CouponCode |
Cart
Name | Type | Default |
---|---|---|
lineItems* | LineItem[] | |
summaryItems* | CartSummaryItem[] | |
total* | string | |
totalLabel | string |
CartState
Name | Type | Default |
---|---|---|
lineItems* | LineItem[] | |
lastResult* | SubmissionResult | null |
CouponCode
Name | Type | Default |
---|---|---|
action* | Action<CouponCodeFormState, FormData> | |
couponCodes | string[] | |
ctaLabel | string | |
disabled | boolean | |
label | string | |
placeholder | string | |
removeLabel | string |
CouponCodeFormState
Name | Type | Default |
---|---|---|
couponCodes* | string[] | |
lastResult | SubmissionResult | null |
CartEmptyState
Name | Type | Default |
---|---|---|
title* | string | |
subtitle* | string | |
cta* | CTA |
LineItem
Name | Type | Default |
---|---|---|
id* | string | |
image* | Image | |
title* | string | |
subtitle* | string | |
quantity* | number | |
price* | string |
CartSummaryItem
Name | Type | Default |
---|---|---|
label* | string | |
value* | string |
Image
Name | Type | Default |
---|---|---|
src* | string | |
alt* | string |
CTA
Name | Type | Default |
---|---|---|
label* | string | |
href* | string |
CSS Variables
This component supports various CSS variables for theming. Here's a comprehensive list.
:root { --cart-focus: hsl(var(--primary)); --cart-font-family: var(--font-family-body); --cart-title-font-family: var(--font-family-heading); --cart-text: hsl(var(--foreground)); --cart-subtitle-text: hsl(var(--contrast-500)); --cart-subtext-text: hsl(var(--contrast-300)); --cart-icon: hsl(var(--contrast-300)); --cart-icon-hover: hsl(var(--foreground)); --cart-border: hsl(var(--contrast-100)); --cart-image-background: hsl(var(--contrast-100)); --cart-button-background: hsl(var(--contrast-100)); --cart-counter-icon: hsl(var(--contrast-300)); --cart-counter-icon-hover: hsl(var(--foreground)); --cart-counter-background: hsl(var(--background)); --cart-counter-background-hover: hsl(var(--contrast-100) / 50%);}