Repository: clerk/javascript
Pull request #5570: feat(clerk-js,types): Add `PaymentSourceRow` for displaying payment sources
Author: login: aeliox
Labels: name: clerk-js
Created: 2025-04-09T18:20:13Z | Merged: 2025-04-13T17:25:19Z

## Description

Add component for consistently displaying a payment source row across various Commerce components.

Also makes the checkout form pre-select the default payment source.

<img width="638" alt="Screenshot 2025-04-09 at 11 19 10 AM" src="https://github.com/user-attachments/assets/d0347bb7-d0be-4366-b752-b82c3004686a" />


## Checklist

- [ ] `pnpm test` runs as expected.
- [ ] `pnpm build` runs as expected.
- [ ] (If applicable) [JSDoc comments](https://jsdoc.app/about-getting-started.html) have been added or updated for any package exports
- [ ] (If applicable) [Documentation](https://github.com/clerk/clerk-docs) has been updated

## Type of change

- [ ] 🐛 Bug fix
- [x] 🌟 New feature
- [ ] 🔨 Breaking change
- [ ] 📖 Refactoring / dependency upgrade / documentation
- [ ] other:



Diff:
diff --git a/.changeset/tall-clocks-call.md b/.changeset/tall-clocks-call.md
new file mode 100644
index 00000000000..77eb34eb2ce
--- /dev/null
+++ b/.changeset/tall-clocks-call.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-js': patch
+---
+
+Add `PaymentSourceRow` for displaying payment sources
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index 14ea235d97e..2a1d79e693f 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -21,7 +21,7 @@
     { "path": "./dist/keylessPrompt*.js", "maxSize": "5.9KB" },
     { "path": "./dist/pricingTable*.js", "maxSize": "5KB" },
     { "path": "./dist/checkout*.js", "maxSize": "3KB" },
-    { "path": "./dist/paymentSources*.js", "maxSize": "8.1KB" },
+    { "path": "./dist/paymentSources*.js", "maxSize": "8.2KB" },
     { "path": "./dist/up-billing-page*.js", "maxSize": "1KB" },
     { "path": "./dist/op-billing-page*.js", "maxSize": "1KB" },
     { "path": "./dist/sessionTasks*.js", "maxSize": "1KB" }
diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
index 0bd7b23a7f1..4911ccaad2e 100644
--- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
+++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
@@ -9,13 +9,13 @@ import type {
 import { useMemo, useState } from 'react';
 
 import { __experimental_PaymentSourcesContext, useCheckoutContext } from '../../contexts';
-import { Box, Button, Col, descriptors, Flex, Form, Icon, localizationKeys, Text } from '../../customizables';
+import { Box, Button, Col, descriptors, Form, localizationKeys } from '../../customizables';
 import { Alert, Disclosure, Divider, Drawer, LineItems, Select, SelectButton, SelectOptionList } from '../../elements';
 import { useFetch } from '../../hooks';
-import { ArrowUpDown, CreditCard } from '../../icons';
+import { ArrowUpDown } from '../../icons';
 import { animations } from '../../styledSystem';
 import { handleError } from '../../utils';
-import { AddPaymentSource } from '../PaymentSources';
+import { AddPaymentSource, PaymentSourceRow } from '../PaymentSources';
 
 const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1);
 
@@ -170,6 +170,7 @@ const CheckoutFormElements = ({
             <Disclosure.Content>
               <Col gap={3}>
                 <PaymentSourceMethods
+                  checkout={checkout}
                   paymentSources={paymentSources}
                   totalDueNow={checkout.totals.totalDueNow || checkout.totals.grandTotal}
                   onPaymentSourceSubmit={onPaymentSourceSubmit}
@@ -208,11 +209,13 @@ const CheckoutFormElements = ({
 };
 
 const PaymentSourceMethods = ({
+  checkout,
   totalDueNow,
   paymentSources,
   onPaymentSourceSubmit,
   isSubmitting,
 }: {
+  checkout: __experimental_CommerceCheckoutResource;
   totalDueNow: __experimental_CommerceMoney;
   paymentSources: __experimental_CommercePaymentSourceResource[];
   onPaymentSourceSubmit: React.FormEventHandler<HTMLFormElement>;
@@ -220,7 +223,7 @@ const PaymentSourceMethods = ({
 }) => {
   const [selectedPaymentSource, setSelectedPaymentSource] = useState<
     __experimental_CommercePaymentSourceResource | undefined
-  >(paymentSources.length > 0 ? paymentSources[0] : undefined);
+  >(checkout.paymentSource || paymentSources.find(p => p.isDefault));
 
   const options = useMemo(() => {
     return paymentSources.map(source => {
@@ -241,7 +244,7 @@ const PaymentSourceMethods = ({
       })}
     >
       <Select
-        elementId='role'
+        elementId='paymentSource'
         options={options}
         value={selectedPaymentSource?.id || null}
         onChange={option => {
@@ -263,20 +266,7 @@ const PaymentSourceMethods = ({
             backgroundColor: t.colors.$colorBackground,
           })}
         >
-          {selectedPaymentSource && (
-            <Flex
-              gap={3}
-              align='center'
-            >
-              <Icon icon={CreditCard} />
-              <Text
-                as='span'
-                colorScheme='body'
-              >
-                {capitalize(selectedPaymentSource.cardType)} ⋯ {selectedPaymentSource.last4}
-              </Text>
-            </Flex>
-          )}
+          {selectedPaymentSource && <PaymentSourceRow paymentSource={selectedPaymentSource} />}
         </SelectButton>
         <SelectOptionList
           sx={t => ({
diff --git a/packages/clerk-js/src/ui/components/PaymentSources/PaymentSourceRow.tsx b/packages/clerk-js/src/ui/components/PaymentSources/PaymentSourceRow.tsx
new file mode 100644
index 00000000000..c6427cec301
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/PaymentSources/PaymentSourceRow.tsx
@@ -0,0 +1,55 @@
+import type { __experimental_CommercePaymentSourceResource } from '@clerk/types';
+
+import { Badge, descriptors, Flex, Icon, localizationKeys, Text } from '../../customizables';
+import { ApplePay, CreditCard } from '../../icons';
+
+export const PaymentSourceRow = ({
+  paymentSource,
+}: {
+  paymentSource: __experimental_CommercePaymentSourceResource;
+}) => {
+  return (
+    <Flex
+      sx={{ overflow: 'hidden' }}
+      gap={2}
+      align='baseline'
+      elementDescriptor={descriptors.paymentSourceRow}
+    >
+      <Icon
+        icon={paymentSource.walletType === 'apple_pay' ? ApplePay : CreditCard}
+        sx={{ alignSelf: 'center' }}
+        elementDescriptor={descriptors.paymentSourceRowIcon}
+      />
+      <Text
+        sx={t => ({ color: t.colors.$colorText, textTransform: 'capitalize' })}
+        truncate
+        elementDescriptor={descriptors.paymentSourceRowType}
+      >
+        {paymentSource.paymentMethod === 'card' ? paymentSource.cardType : paymentSource.paymentMethod}
+      </Text>
+      <Text
+        sx={t => ({ color: t.colors.$colorTextSecondary })}
+        variant='caption'
+        truncate
+        elementDescriptor={descriptors.paymentSourceRowValue}
+      >
+        {paymentSource.paymentMethod === 'card' ? `⋯ ${paymentSource.last4}` : '-'}
+      </Text>
+      {paymentSource.isDefault && (
+        <Badge
+          elementDescriptor={descriptors.paymentSourceRowBadge}
+          elementId={descriptors.paymentSourceRowBadge.setId('default')}
+          localizationKey={localizationKeys('badge__default')}
+        />
+      )}
+      {paymentSource.status === 'expired' && (
+        <Badge
+          elementDescriptor={descriptors.paymentSourceRowBadge}
+          elementId={descriptors.paymentSourceRowBadge.setId('expired')}
+          colorScheme='danger'
+          localizationKey={localizationKeys('badge__expired')}
+        />
+      )}
+    </Flex>
+  );
+};
diff --git a/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx b/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx
index 64355c1915c..cb98e2390f2 100644
--- a/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx
+++ b/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx
@@ -4,14 +4,14 @@ import { Fragment, useRef } from 'react';
 
 import { RemoveResourceForm } from '../../common';
 import { usePaymentSourcesContext } from '../../contexts';
-import { Badge, Flex, Icon, localizationKeys, Text } from '../../customizables';
+import { localizationKeys } from '../../customizables';
 import { ProfileSection, ThreeDotsMenu, useCardState } from '../../elements';
 import { Action } from '../../elements/Action';
 import { useActionContext } from '../../elements/Action/ActionRoot';
 import { useFetch } from '../../hooks';
-import { ApplePay, CreditCard } from '../../icons';
 import { handleError } from '../../utils';
 import { AddPaymentSource } from './AddPaymentSource';
+import { PaymentSourceRow } from './PaymentSourceRow';
 
 const AddScreen = ({ onSuccess }: { onSuccess: () => void }) => {
   const { close } = useActionContext();
@@ -108,36 +108,7 @@ const PaymentSources = (_: __experimental_PaymentSourcesProps) => {
           {paymentSources.map(paymentSource => (
             <Fragment key={paymentSource.id}>
               <ProfileSection.Item id='paymentSources'>
-                <Flex
-                  sx={{ overflow: 'hidden' }}
-                  gap={2}
-                  align='baseline'
-                >
-                  <Icon
-                    icon={paymentSource.walletType === 'apple_pay' ? ApplePay : CreditCard}
-                    sx={{ alignSelf: 'center' }}
-                  />
-                  <Text
-                    sx={t => ({ color: t.colors.$colorText, textTransform: 'capitalize' })}
-                    truncate
-                  >
-                    {paymentSource.paymentMethod === 'card' ? paymentSource.cardType : paymentSource.paymentMethod}
-                  </Text>
-                  <Text
-                    sx={t => ({ color: t.colors.$colorTextSecondary })}
-                    variant='caption'
-                    truncate
-                  >
-                    {paymentSource.paymentMethod === 'card' ? `⋯ ${paymentSource.last4}` : '-'}
-                  </Text>
-                  {paymentSource.isDefault && <Badge localizationKey={localizationKeys('badge__default')} />}
-                  {paymentSource.status === 'expired' && (
-                    <Badge
-                      colorScheme='danger'
-                      localizationKey={localizationKeys('badge__expired')}
-                    />
-                  )}
-                </Flex>
+                <PaymentSourceRow paymentSource={paymentSource} />
                 <PaymentSourceMenu
                   paymentSource={paymentSource}
                   revalidate={revalidate}
diff --git a/packages/clerk-js/src/ui/components/PaymentSources/index.ts b/packages/clerk-js/src/ui/components/PaymentSources/index.ts
index 74008535779..d770055e73b 100644
--- a/packages/clerk-js/src/ui/components/PaymentSources/index.ts
+++ b/packages/clerk-js/src/ui/components/PaymentSources/index.ts
@@ -1,2 +1,3 @@
 export * from './AddPaymentSource';
 export * from './PaymentSources';
+export * from './PaymentSourceRow';
diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
index 67e9d8c5aaa..8ddf8587e21 100644
--- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
+++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
@@ -330,6 +330,13 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
   'selectOptionsContainer',
   'selectOption',
 
+  'paymentSourceRow',
+  'paymentSourceRowIcon',
+  'paymentSourceRowText',
+  'paymentSourceRowType',
+  'paymentSourceRowValue',
+  'paymentSourceRowBadge',
+
   'menuButton',
   'menuButtonEllipsis',
   'menuList',
diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts
index 7db117b5895..921055af05f 100644
--- a/packages/types/src/appearance.ts
+++ b/packages/types/src/appearance.ts
@@ -454,6 +454,13 @@ export type ElementsConfig = {
   selectOptionsContainer: WithOptions<SelectId>;
   selectOption: WithOptions<SelectId>;
 
+  paymentSourceRow: WithOptions;
+  paymentSourceRowIcon: WithOptions;
+  paymentSourceRowText: WithOptions;
+  paymentSourceRowType: WithOptions;
+  paymentSourceRowValue: WithOptions;
+  paymentSourceRowBadge: WithOptions<'default' | 'expired'>;
+
   menuButton: WithOptions<MenuId>;
   menuButtonEllipsis: WithOptions;
   menuList: WithOptions<MenuId>;
diff --git a/packages/types/src/elementIds.ts b/packages/types/src/elementIds.ts
index 9b9872070c7..43ada509c5b 100644
--- a/packages/types/src/elementIds.ts
+++ b/packages/types/src/elementIds.ts
@@ -52,4 +52,4 @@ export type OrganizationPreviewId =
 export type CardActionId = 'havingTrouble' | 'alternativeMethods' | 'signUp' | 'signIn' | 'usePasskey' | 'waitlist';
 
 export type MenuId = 'invitation' | 'member' | ProfileSectionId;
-export type SelectId = 'countryCode' | 'role';
+export type SelectId = 'countryCode' | 'role' | 'paymentSource';


Reviews:
user: login: alexcarpenter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T18:56:43Z
user: login: alexcarpenter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T12:11:47Z
user: login: alexcarpenter
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-11T12:11:57Z
user: login: iagodahlem
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-11T12:28:32Z

Review comments:
diff_hunk: @@ -0,0 +1,43 @@
+import type { __experimental_CommercePaymentSourceResource } from '@clerk/types';
+
+import { Badge, Flex, Icon, localizationKeys, Text } from '../../customizables';
+import { ApplePay, CreditCard } from '../../icons';
+
+export const PaymentSourceRow = ({
+  paymentSource,
+}: {
+  paymentSource: __experimental_CommercePaymentSourceResource;
+}) => {
+  return (
+    <Flex
+      sx={{ overflow: 'hidden' }}
+      gap={2}
+      align='baseline'
+    >
+      <Icon
+        icon={paymentSource.walletType === 'apple_pay' ? ApplePay : CreditCard}
+        sx={{ alignSelf: 'center' }}
+      />
+      <Text
+        sx={t => ({ color: t.colors.$colorText, textTransform: 'capitalize' })}
+        truncate
+      >
+        {paymentSource.paymentMethod === 'card' ? paymentSource.cardType : paymentSource.paymentMethod}
+      </Text>
+      <Text
+        sx={t => ({ color: t.colors.$colorTextSecondary })}
+        variant='caption'
+        truncate
+      >
+        {paymentSource.paymentMethod === 'card' ? `⋯ ${paymentSource.last4}` : '-'}
+      </Text>
+      {paymentSource.isDefault && <Badge localizationKey={localizationKeys('badge__default')} />}
+      {paymentSource.status === 'expired' && (
+        <Badge
+          colorScheme='danger'
+          localizationKey={localizationKeys('badge__expired')}
+        />
+      )}
path: packages/clerk-js/src/ui/components/PaymentSources/PaymentSourceRow.tsx
user: login: alexcarpenter
body: We should add descriptors for these elements.
created_at: 2025-04-09T18:56:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,43 @@
+import type { __experimental_CommercePaymentSourceResource } from '@clerk/types';
+
+import { Badge, Flex, Icon, localizationKeys, Text } from '../../customizables';
+import { ApplePay, CreditCard } from '../../icons';
+
+export const PaymentSourceRow = ({
+  paymentSource,
+}: {
+  paymentSource: __experimental_CommercePaymentSourceResource;
+}) => {
+  return (
+    <Flex
+      sx={{ overflow: 'hidden' }}
+      gap={2}
+      align='baseline'
+    >
+      <Icon
+        icon={paymentSource.walletType === 'apple_pay' ? ApplePay : CreditCard}
+        sx={{ alignSelf: 'center' }}
+      />
+      <Text
+        sx={t => ({ color: t.colors.$colorText, textTransform: 'capitalize' })}
+        truncate
+      >
+        {paymentSource.paymentMethod === 'card' ? paymentSource.cardType : paymentSource.paymentMethod}
+      </Text>
+      <Text
+        sx={t => ({ color: t.colors.$colorTextSecondary })}
+        variant='caption'
+        truncate
+      >
+        {paymentSource.paymentMethod === 'card' ? `⋯ ${paymentSource.last4}` : '-'}
+      </Text>
+      {paymentSource.isDefault && <Badge localizationKey={localizationKeys('badge__default')} />}
+      {paymentSource.status === 'expired' && (
+        <Badge
+          colorScheme='danger'
+          localizationKey={localizationKeys('badge__expired')}
+        />
+      )}
path: packages/clerk-js/src/ui/components/PaymentSources/PaymentSourceRow.tsx
user: login: alexcarpenter
body: https://github.com/clerk/javascript/pull/5570/commits/fd0ba8deb5d3a529d551c29c3ae30ecf3bcdd59b
created_at: 2025-04-11T12:11:47Z
author_association: MEMBER

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-04-09T18:20:17Z
body: ###  🦋  Changeset detected

Latest commit: 1ef5a366c3bfe16c36324446133264a077751a24

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 3 packages</summary>

  | Name                    | Type  |
| ----------------------- | ----- |
| @clerk/clerk-js         | Patch |
| @clerk/chrome-extension | Patch |
| @clerk/clerk-expo       | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/clerk/javascript/new/keiran/payment-source-row?filename=.changeset/stale-paws-nail.md&value=---%0A%22%40clerk%2Fclerk-js%22%3A%20patch%0A%22%40clerk%2Ftypes%22%3A%20patch%0A---%0A%0Afeat(clerk-js%2Ctypes)%3A%20Add%20%60PaymentSourceRow%60%20for%20displaying%20payment%20sources%0A)


author_association: NONE
user: login: vercel[bot]
created_at: 2025-04-09T18:20:18Z
body: [vc]: #uhQuF5r06zRe4BiG5PfS2EcxYQ1fgrwXG/hOBEWjuZU=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJjbGVyay1qcy1zYW5kYm94IiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImNsZXJrLWpzLXNhbmRib3gtZ2l0LWtlaXJhbi1wYXltZW50LXNvdXJjZS1yb3cuY2xlcmtzdGFnZS5kZXYifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2NsZXJrLXByb2R1Y3Rpb24vY2xlcmstanMtc2FuZGJveC80WGRTWThEV1RIbnpHRlNmajRZWGo0VmFDTmRGIiwicHJldmlld1VybCI6ImNsZXJrLWpzLXNhbmRib3gtZ2l0LWtlaXJhbi1wYXltZW50LXNvdXJjZS1yb3cuY2xlcmtzdGFnZS5kZXYiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJyb290RGlyZWN0b3J5IjoicGFja2FnZXMvY2xlcmstanMifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **clerk-js-sandbox** | ✅ Ready ([Inspect](https://vercel.com/clerk-production/clerk-js-sandbox/4XdSY8DWTHnzGFSfj4YXj4VaCNdF)) | [Visit Preview](https://clerk-js-sandbox-git-keiran-payment-source-row.clerkstage.dev) | 💬 [**Add feedback**](https://vercel.live/open-feedback/clerk-js-sandbox-git-keiran-payment-source-row.clerkstage.dev?via=pr-comment-feedback-link) | Apr 11, 2025 0:35am |


author_association: NONE