diff --git a/src/components/Shared/SubmitButton/SubmitButton.test.tsx b/src/components/Shared/SubmitButton/SubmitButton.test.tsx
new file mode 100644
index 0000000000..bf20be1624
--- /dev/null
+++ b/src/components/Shared/SubmitButton/SubmitButton.test.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SubmitButton } from './SubmitButton';
+
+describe('SubmitButton', () => {
+ it('calls onSubmit when clicked', async () => {
+ const onSubmit = jest.fn().mockResolvedValue(undefined);
+ const { getByRole } = render(
+ Submit,
+ );
+
+ await userEvent.click(getByRole('button', { name: 'Submit' }));
+
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/Shared/SubmitButton/SubmitButton.tsx b/src/components/Shared/SubmitButton/SubmitButton.tsx
new file mode 100644
index 0000000000..c3e61cea82
--- /dev/null
+++ b/src/components/Shared/SubmitButton/SubmitButton.tsx
@@ -0,0 +1,35 @@
+import React, { useState } from 'react';
+import { Button } from '@mui/material';
+
+interface SubmitButtonProps {
+ /**
+ * Async submit handler. The button must not invoke this more than once
+ * per in-flight submission (guards against duplicate requests).
+ */
+ onSubmit: () => Promise;
+ children: React.ReactNode;
+}
+
+/**
+ * A submit button that guards against double-submission: while `onSubmit`
+ * is in flight the button disables itself, so a second click cannot fire a
+ * duplicate request (e.g. creating two donations from one intent).
+ */
+export const SubmitButton: React.FC = ({
+ onSubmit,
+ children,
+}) => {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handleClick = async () => {
+ // Runs the submit handler and re-enables the button when it settles.
+ await onSubmit();
+ setIsSubmitting(false);
+ };
+
+ return (
+
+ );
+};