diff --git a/frontend/src/metabase-types/api/dashboard.ts b/frontend/src/metabase-types/api/dashboard.ts
index dc335949d4152d60d1acc6eb0b48208aad961edd..e40f371cc85384b067cce4164aff114addfa8eb2 100644
--- a/frontend/src/metabase-types/api/dashboard.ts
+++ b/frontend/src/metabase-types/api/dashboard.ts
@@ -72,7 +72,7 @@ export type VirtualCard = Partial<Card> & {
 };
 
 export type DashboardCard = BaseDashboardCard & {
-  card_id: CardId | null;
+  card_id: CardId | null; // will be null for virtual card
   card: Card;
   parameter_mappings?: DashboardParameterMapping[] | null;
   series?: Card[];
diff --git a/frontend/src/metabase/dashboard/actions/cards.js b/frontend/src/metabase/dashboard/actions/cards.js
index a9f767a2bb9acb2bee68117c3b26d03ec7891a8f..20ac2ab884635031e4f9dcc8d62b1a39b3076780 100644
--- a/frontend/src/metabase/dashboard/actions/cards.js
+++ b/frontend/src/metabase/dashboard/actions/cards.js
@@ -30,7 +30,7 @@ export const markNewCardSeen = createAction(MARK_NEW_CARD_SEEN);
 
 let tempId = -1;
 
-function generateTemporaryDashcardId() {
+export function generateTemporaryDashcardId() {
   return tempId--;
 }
 
diff --git a/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/DashCardActionsPanel.tsx b/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/DashCardActionsPanel.tsx
index 4b7dedfe81b35d63954252185c178b1666af8edb..440c22b16962de8e6a791de7c00868587171ee8b 100644
--- a/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/DashCardActionsPanel.tsx
+++ b/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/DashCardActionsPanel.tsx
@@ -16,6 +16,7 @@ import type {
 import { isActionDashCard } from "metabase/actions/utils";
 import { isLinkDashCard, isVirtualDashCard } from "metabase/dashboard/utils";
 
+import { useDuplicateDashCard } from "./use-duplicate-dashcard";
 import { ChartSettingsButton } from "./ChartSettingsButton/ChartSettingsButton";
 import { DashCardTabMenu } from "./DashCardTabMenu/DashCardTabMenu";
 import { DashCardActionButton } from "./DashCardActionButton/DashCardActionButton";
@@ -71,10 +72,10 @@ export function DashCardActionsPanel({
     disableClickBehavior,
   } = getVisualizationRaw(series) ?? {};
 
-  const [isDashCardTabMenuOpen, setIsDashCardTabMenuOpen] = useState(false);
-
   const buttons = [];
 
+  const [isDashCardTabMenuOpen, setIsDashCardTabMenuOpen] = useState(false);
+
   if (dashcard) {
     buttons.push(
       <DashCardTabMenu
@@ -145,6 +146,20 @@ export function DashCardActionsPanel({
     );
   }
 
+  const duplicateDashcard = useDuplicateDashCard({ dashboard, dashcard });
+  if (!isLoading && dashcard) {
+    buttons.push(
+      <DashCardActionButton
+        key="duplicate-question"
+        aria-label={t`Duplicate`}
+        tooltip={t`Duplicate`}
+        onClick={duplicateDashcard}
+      >
+        <Icon name="copy" />
+      </DashCardActionButton>,
+    );
+  }
+
   if (!isLoading && !hasError) {
     if (supportsSeries) {
       buttons.push(
diff --git a/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/use-duplicate-dashcard.ts b/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/use-duplicate-dashcard.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c0a4b6039648b87441659a97a5f990a062128900
--- /dev/null
+++ b/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/use-duplicate-dashcard.ts
@@ -0,0 +1,80 @@
+import { useCallback } from "react";
+
+import { createAction, useDispatch, useSelector } from "metabase/lib/redux";
+import { getPositionForNewDashCard } from "metabase/lib/dashboard_grid";
+import {
+  getCardData,
+  getDashboards,
+  getDashcards,
+  getSelectedTabId,
+} from "metabase/dashboard/selectors";
+import {
+  FETCH_CARD_DATA,
+  addDashCardToDashboard,
+  generateTemporaryDashcardId,
+} from "metabase/dashboard/actions";
+import { getExistingDashCards } from "metabase/dashboard/actions/utils";
+import { isVirtualDashCard } from "metabase/dashboard/utils";
+import type { Dashboard, DashboardCard } from "metabase-types/api";
+
+export function useDuplicateDashCard({
+  dashboard,
+  dashcard,
+}: {
+  dashboard: Dashboard;
+  dashcard: DashboardCard | undefined;
+}) {
+  const dispatch = useDispatch();
+  const dashboards = useSelector(getDashboards);
+  const dashcards = useSelector(getDashcards);
+  const selectedTabId = useSelector(getSelectedTabId);
+  const dashcardDataMap = useSelector(getCardData);
+
+  return useCallback(() => {
+    if (!dashcard) {
+      return () => {
+        throw new Error(
+          "duplicateDashcard was called with an undefined dashcard",
+        );
+      };
+    }
+
+    const newId = generateTemporaryDashcardId();
+    const { id: _id, ...newDashcard } = dashcard;
+
+    const position = getPositionForNewDashCard(
+      getExistingDashCards(dashboards, dashcards, dashboard.id, selectedTabId),
+      dashcard.size_x,
+      dashcard.size_y,
+    );
+
+    dispatch(
+      addDashCardToDashboard({
+        dashId: dashboard.id,
+        dashcardOverrides: { id: newId, ...newDashcard, ...position },
+        tabId: selectedTabId,
+      }),
+    );
+
+    // We don't have card (question) data for virtual dashcards (text, heading, link, action)
+    if (!isVirtualDashCard(dashcard) && dashcard.card_id !== null) {
+      dispatch(
+        // Manually copying the card data by dispatching the `FETCH_CARD_DATA` action directly,
+        // as opposed to using the `fetchCardData` thunk, will send a request to re-fetch the data
+        createAction(FETCH_CARD_DATA)({
+          dashcard_id: newId,
+          card_id: dashcard.card_id,
+          result: dashcardDataMap[dashcard.id][dashcard?.card_id],
+        }),
+      );
+    }
+  }, [
+    dispatch,
+    dashboard.id,
+    dashboards,
+    dashcard,
+    dashcards,
+    dashcardDataMap,
+    selectedTabId,
+  ]);
+}