diff --git a/.changeset/large-cobras-fold.md b/.changeset/large-cobras-fold.md
new file mode 100644
index 0000000000..31c8c96dab
--- /dev/null
+++ b/.changeset/large-cobras-fold.md
@@ -0,0 +1,5 @@
+---
+'@shopify/hydrogen-react': patch
+---
+
+Adds BuyNowButton that adds an item to the cart and redirects the customer to checkout.
diff --git a/packages/react/src/BuyNowButton.test.tsx b/packages/react/src/BuyNowButton.test.tsx
new file mode 100644
index 0000000000..328bb04217
--- /dev/null
+++ b/packages/react/src/BuyNowButton.test.tsx
@@ -0,0 +1,149 @@
+import {CartProvider, useCart} from './CartProvider.js';
+import {render, screen} from '@testing-library/react';
+import {vi} from 'vitest';
+import userEvent from '@testing-library/user-event';
+import {BuyNowButton} from './BuyNowButton.js';
+
+vi.mock('./CartProvider');
+
+const defaultCart = {
+ buyerIdentityUpdate: vi.fn(),
+ cartAttributesUpdate: vi.fn(),
+ cartCreate: vi.fn(),
+ cartFragment: '',
+ checkoutUrl: '',
+ discountCodesUpdate: vi.fn(),
+ linesAdd: vi.fn(),
+ linesRemove: vi.fn(),
+ linesUpdate: vi.fn(),
+ noteUpdate: vi.fn(),
+ status: 'idle' as const,
+ totalQuantity: 0,
+};
+
+describe('', () => {
+ it('renders a button', () => {
+ render(Buy now, {
+ wrapper: CartProvider,
+ });
+ expect(screen.getByRole('button')).toHaveTextContent('Buy now');
+ });
+
+ it('can optionally disable the button', () => {
+ render(
+
+ Buy now
+ ,
+ {
+ wrapper: CartProvider,
+ }
+ );
+
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+
+ it('allows pass-through props', () => {
+ render(
+
+ Buy now
+ ,
+ {
+ wrapper: CartProvider,
+ }
+ );
+
+ expect(screen.getByRole('button')).toHaveClass('fancy-button');
+ });
+
+ describe('when the button is clicked', () => {
+ it('uses useCartCreateCallback with the correct arguments', async () => {
+ const mockCartCreate = vi.fn();
+
+ vi.mocked(useCart).mockImplementation(() => ({
+ ...defaultCart,
+ cartCreate: mockCartCreate,
+ }));
+
+ const user = userEvent.setup();
+
+ render(
+
+ Buy now
+ ,
+ {
+ wrapper: CartProvider,
+ }
+ );
+
+ await user.click(screen.getByRole('button'));
+
+ expect(mockCartCreate).toHaveBeenCalledTimes(1);
+ expect(mockCartCreate).toHaveBeenCalledWith({
+ lines: [
+ {
+ quantity: 4,
+ merchandiseId: 'SKU123',
+ attributes: [
+ {key: 'color', value: 'blue'},
+ {key: 'size', value: 'large'},
+ ],
+ },
+ ],
+ });
+ });
+
+ it('disables the button', async () => {
+ const user = userEvent.setup();
+
+ render(Buy now, {
+ wrapper: CartProvider,
+ });
+
+ const button = screen.getByRole('button');
+
+ expect(button).not.toBeDisabled();
+
+ await user.click(button);
+
+ expect(button).toBeDisabled();
+ });
+ });
+
+ describe('when a checkout URL is available', () => {
+ const {location} = window;
+ const mockSetHref = vi.fn((href) => href);
+
+ beforeEach(() => {
+ delete (window as Partial).location;
+ window.location = {...window.location};
+ Object.defineProperty(window.location, 'href', {
+ set: mockSetHref,
+ });
+ });
+
+ afterEach(() => {
+ window.location = location;
+ });
+
+ it('redirects to checkout', () => {
+ vi.mocked(useCart).mockImplementation(() => ({
+ ...defaultCart,
+ checkoutUrl: '/checkout?id=123',
+ }));
+
+ render(Buy now, {
+ wrapper: CartProvider,
+ });
+
+ expect(mockSetHref).toHaveBeenCalledTimes(1);
+ expect(mockSetHref).toHaveBeenCalledWith('/checkout?id=123');
+ });
+ });
+});
diff --git a/packages/react/src/BuyNowButton.tsx b/packages/react/src/BuyNowButton.tsx
new file mode 100644
index 0000000000..f105b10c8c
--- /dev/null
+++ b/packages/react/src/BuyNowButton.tsx
@@ -0,0 +1,62 @@
+import {useEffect, useState, useCallback} from 'react';
+import {useCart} from './CartProvider.js';
+import {BaseButton, BaseButtonProps} from './BaseButton.js';
+
+interface BuyNowButtonProps {
+ /** The item quantity. Defaults to 1. */
+ quantity?: number;
+ /** The ID of the variant. */
+ variantId: string;
+ /** An array of cart line attributes that belong to the item being added to the cart. */
+ attributes?: {
+ key: string;
+ value: string;
+ }[];
+}
+
+/** The `BuyNowButton` component renders a button that adds an item to the cart and redirects the customer to checkout. */
+export function BuyNowButton(
+ props: BuyNowButtonProps & BaseButtonProps
+) {
+ const {cartCreate, checkoutUrl} = useCart();
+ const [loading, setLoading] = useState(false);
+
+ const {
+ quantity,
+ variantId,
+ onClick,
+ attributes,
+ children,
+ ...passthroughProps
+ } = props;
+
+ useEffect(() => {
+ if (checkoutUrl) {
+ window.location.href = checkoutUrl;
+ }
+ }, [checkoutUrl]);
+
+ const handleBuyNow = useCallback(() => {
+ setLoading(true);
+ cartCreate({
+ lines: [
+ {
+ quantity: quantity ?? 1,
+ merchandiseId: variantId,
+ attributes,
+ },
+ ],
+ });
+ }, [cartCreate, quantity, variantId, attributes]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index cbeed5e866..6e8815fae5 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -1,4 +1,5 @@
export {AddToCartButton} from './AddToCartButton.js';
+export {BuyNowButton} from './BuyNowButton.js';
export type {
CartState,
CartStatus,