diff --git a/changelog/dashboard-card.yaml b/changelog/dashboard-card.yaml
new file mode 100644
index 00000000000..f3a2ab9f4f4
--- /dev/null
+++ b/changelog/dashboard-card.yaml
@@ -0,0 +1,4 @@
+type: Added
+description: Create Card and Statistic HOCs, demonstrate in StatCard
+pr: 7477
+labels: []
diff --git a/clients/fidesui/src/components/charts/RadarChart.tsx b/clients/fidesui/src/components/charts/RadarChart.tsx
index 615bad449bd..2dfa761b5d7 100644
--- a/clients/fidesui/src/components/charts/RadarChart.tsx
+++ b/clients/fidesui/src/components/charts/RadarChart.tsx
@@ -132,7 +132,7 @@ export const RadarChart = ({
data={empty ? EMPTY_PLACEHOLDER_DATA : data}
cx="50%"
cy="50%"
- outerRadius="70%"
+ outerRadius="80%"
>
- {!empty && (
-
- }
- />
- )}
-
+
+ {!empty && (
+
+ }
+ />
+ )}
diff --git a/clients/fidesui/src/components/dashboard/StatCard.stories.tsx b/clients/fidesui/src/components/dashboard/StatCard.stories.tsx
new file mode 100644
index 00000000000..a31a13d411f
--- /dev/null
+++ b/clients/fidesui/src/components/dashboard/StatCard.stories.tsx
@@ -0,0 +1,293 @@
+import { ArrowDownOutlined, ArrowUpOutlined } from "@ant-design/icons";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Flex, theme } from "antd";
+import { Card, Statistic } from "fidesui";
+import { useState } from "react";
+
+import { RadarChart } from "../charts/RadarChart";
+import { Sparkline } from "../charts/Sparkline";
+
+const upwardTrendData = [12, 18, 15, 22, 28, 25, 34, 30, 38, 42, 39, 47];
+
+const downwardTrendData = [47, 42, 45, 38, 34, 36, 28, 24, 26, 18, 15, 12];
+
+const neutralTrendData = [28, 32, 25, 30, 27, 33, 26, 31, 28, 34, 29, 30];
+
+const meta = {
+ title: "Dashboard/Card",
+ component: Card,
+ parameters: {
+ layout: "centered",
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ coverPosition: {
+ description:
+ "Position of the cover relative to the card body. Use `bottom` to place a sparkline below the stat.",
+ control: "radio",
+ options: ["top", "bottom"],
+ },
+ cover: {
+ description:
+ 'Cover content (e.g. a sparkline). Set `coverPosition="bottom"` to display it below the card body.',
+ control: false,
+ },
+ children: {
+ description: "Card body content, e.g. `` component.",
+ control: false,
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ variant: "borderless",
+ coverPosition: "bottom",
+ title: "Default Card",
+ },
+ render: (args) => {
+ const { token } = theme.useToken();
+ return (
+
+
+ }
+ valueStyle={{ fontSize: token.fontSize }}
+ />
+
+ );
+ },
+};
+
+export const NoTitle: Story = {
+ args: {
+ variant: "borderless",
+ coverPosition: "bottom",
+ },
+ render: (args) => {
+ const { token } = theme.useToken();
+ return (
+
+
+ }
+ valueStyle={{ fontSize: token.fontSize }}
+ />
+
+ );
+ },
+};
+
+export const WithSparkline: Story = {
+ args: {
+ variant: "borderless",
+ cover: (
+
+
+
+ ),
+ title: "Active Users",
+ coverPosition: "bottom",
+ },
+ render: (args) => {
+ const { token } = theme.useToken();
+ return (
+
+
+ }
+ valueStyle={{ fontSize: token.fontSize }}
+ />
+
+ );
+ },
+};
+
+export const WithRadarChart: Story = {
+ args: {
+ variant: "borderless",
+ },
+ render: (args) => {
+ const { token } = theme.useToken();
+ return (
+
+ <>
+
+ }
+ valueStyle={{ fontSize: token.fontSize }}
+ />
+
+
+
+ >
+
+ );
+ },
+};
+
+export const Loading: Story = {
+ args: {
+ variant: "borderless",
+ loading: true,
+ children: ,
+ },
+};
+
+const TAB_CONTENT: Record = {
+ a: "Content A",
+ b: "Content B",
+ c: "Content C",
+};
+
+export const WithTabs: Story = {
+ args: {
+ variant: "borderless",
+ title: "Overview",
+ },
+ render: (args) => {
+ const [activeTab, setActiveTab] = useState("a");
+ return (
+
+
+ {TAB_CONTENT[activeTab]}
+
+
+ );
+ },
+};
+
+export const WithInlineTabs: Story = {
+ args: {
+ variant: "borderless",
+ title: "Overview",
+ headerLayout: "inline",
+ },
+ render: (args) => {
+ const [activeTab, setActiveTab] = useState("a");
+ return (
+
+
+
+ {TAB_CONTENT[activeTab]}
+
+
+
+ );
+ },
+};
+
+export const DashboardRow: Story = {
+ args: {},
+ decorators: [
+ () => {
+ const { token } = theme.useToken();
+ return (
+
+
+
+
+ }
+ coverPosition="bottom"
+ >
+
+ }
+ valueStyle={{ fontSize: token.fontSize }}
+ />
+
+
+
+
+ }
+ coverPosition="bottom"
+ >
+
+ }
+ valueStyle={{ fontSize: token.fontSize }}
+ />
+
+
+
+
+ }
+ coverPosition="bottom"
+ >
+
+
+
+ );
+ },
+ ],
+};
diff --git a/clients/fidesui/src/hoc/CustomCard.module.scss b/clients/fidesui/src/hoc/CustomCard.module.scss
new file mode 100644
index 00000000000..a3b46d4bfbc
--- /dev/null
+++ b/clients/fidesui/src/hoc/CustomCard.module.scss
@@ -0,0 +1,54 @@
+.inlineHeader {
+ :global(.ant-card-head) {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ flex-wrap: nowrap;
+ padding-bottom: 0;
+ }
+
+ :global(.ant-card-head-wrapper) {
+ width: auto;
+ flex: 0 0 auto;
+ margin-right: var(--ant-card-padding-lg, 24px);
+ margin-bottom: 0;
+ padding-bottom: 0;
+ }
+
+ :global(.ant-card-head-tabs) {
+ flex: 0 0 auto;
+ margin-left: auto;
+ margin-top: 0;
+ margin-bottom: 0;
+ min-width: 0;
+ }
+
+ // Ant adds padding-top to title/extra when tabs are present via:
+ // .ant-card-contain-tabs > div.ant-card-head .ant-card-head-title { padding-top: var(--ant-padding) }
+ // The `div` element selector gives that rule specificity (0,3,1) vs our (0,3,0), so we need !important.
+ :global(.ant-card-head .ant-card-head-title),
+ :global(.ant-card-head .ant-card-extra) {
+ padding-top: 0 !important;
+ }
+}
+
+.bottomCover {
+ display: flex;
+ flex-direction: column;
+
+ :global(.ant-card-head) {
+ order: 0;
+ }
+
+ :global(.ant-card-body) {
+ order: 1;
+ }
+
+ :global(.ant-card-cover) {
+ order: 2;
+ }
+
+ :global(.ant-card-actions) {
+ order: 3;
+ }
+}
diff --git a/clients/fidesui/src/hoc/CustomCard.tsx b/clients/fidesui/src/hoc/CustomCard.tsx
new file mode 100644
index 00000000000..a64b4aed806
--- /dev/null
+++ b/clients/fidesui/src/hoc/CustomCard.tsx
@@ -0,0 +1,84 @@
+import { Card, CardProps } from "antd/lib";
+import classNames from "classnames";
+import React from "react";
+
+import styles from "./CustomCard.module.scss";
+
+export interface CustomCardProps extends CardProps {
+ /**
+ * Position of the cover image/content.
+ * - `top`: default Ant Design behaviour (cover above body)
+ * - `bottom`: cover rendered below body via CSS order
+ * @default "top"
+ */
+ coverPosition?: "top" | "bottom";
+ /**
+ * Layout of the card header when both a `title` and `tabList` are provided.
+ * - `"stacked"`: default Ant Design behaviour (title above tabs)
+ * - `"inline"`: title and tabs rendered on the same row, with tabs right-aligned
+ * @default "stacked"
+ */
+ headerLayout?: "stacked" | "inline";
+}
+
+const withCustomProps = (WrappedComponent: typeof Card) => {
+ const WrappedCard = React.forwardRef(
+ (
+ { coverPosition = "top", headerLayout = "stacked", className, ...props },
+ ref,
+ ) => (
+
+ ),
+ );
+
+ WrappedCard.displayName = "CustomCard";
+ return WrappedCard;
+};
+
+/**
+ * Higher-order component that extends Ant Design's Card with additional layout options.
+ *
+ * Additional props:
+ * @param {"top" | "bottom"} [coverPosition="top"] - Controls where the `cover` content is
+ * displayed relative to the card body. Use `"bottom"` to place a sparkline or chart
+ * below the card content.
+ * @param {"stacked" | "inline"} [headerLayout="stacked"] - Controls how the card header is
+ * laid out when both a `title` and `tabList` are provided. Use `"inline"` to place the
+ * title and tab bar on the same row, with the tabs right-aligned.
+ *
+ * @example
+ * // Card with a sparkline at the bottom
+ * }
+ * coverPosition="bottom"
+ * >
+ *
+ *
+ *
+ * @example
+ * // Card with title left and tabs right-aligned on the same row
+ * const [activeTab, setActiveTab] = useState("a");
+ *
+ * Tab content here
+ *
+ */
+export const CustomCard = Object.assign(withCustomProps(Card), {
+ Meta: Card.Meta,
+ Grid: Card.Grid,
+});
diff --git a/clients/fidesui/src/hoc/CustomStatistic.tsx b/clients/fidesui/src/hoc/CustomStatistic.tsx
new file mode 100644
index 00000000000..d5c75989adf
--- /dev/null
+++ b/clients/fidesui/src/hoc/CustomStatistic.tsx
@@ -0,0 +1,72 @@
+import type { GlobalToken } from "antd";
+import { Statistic, StatisticProps, theme } from "antd/lib";
+import React from "react";
+
+type AntColorTokenKey = Extract;
+
+export type StatisticTrend = "up" | "down" | "neutral";
+
+export interface CustomStatisticProps extends StatisticProps {
+ /**
+ * Trend direction — controls the value colour.
+ * - `up`: maps to `token.colorSuccess`
+ * - `down`: maps to `token.colorError`
+ * - `neutral`: maps to `token.colorText`
+ * @default "neutral"
+ */
+ trend?: StatisticTrend;
+}
+
+/** Maps a trend direction to the corresponding Ant Design color-token key. */
+const TREND_TOKEN_MAP: Record = {
+ up: "colorSuccess",
+ down: "colorError",
+ neutral: "colorText",
+};
+
+const withCustomProps = (WrappedComponent: typeof Statistic) => {
+ const WrappedStatistic = React.forwardRef<
+ React.ComponentRef,
+ CustomStatisticProps
+ >(({ trend = "neutral", valueStyle, ...props }, ref) => {
+ const { token } = theme.useToken();
+ const trendColor = token[TREND_TOKEN_MAP[trend]];
+ return (
+
+ );
+ });
+
+ WrappedStatistic.displayName = "CustomStatistic";
+ return WrappedStatistic;
+};
+
+/**
+ * Higher-order component that extends Ant Design's Statistic with trend-aware
+ * colour and a semibold font-weight default.
+ *
+ * Additional props:
+ * @param {"up" | "down" | "neutral"} [trend="neutral"] - Controls the colour of
+ * the statistic value. `"up"` uses the success colour, `"down"` the error colour,
+ * and `"neutral"` the default text colour.
+ *
+ * @example
+ *
+ *
+ * @example
+ * // Trend indicator below the main stat
+ * }
+ * valueStyle={{ fontSize: token.fontSize }}
+ * />
+ */
+export const CustomStatistic = withCustomProps(Statistic);
diff --git a/clients/fidesui/src/hoc/index.tsx b/clients/fidesui/src/hoc/index.tsx
index eb55abf4de4..7370a6e1b6d 100644
--- a/clients/fidesui/src/hoc/index.tsx
+++ b/clients/fidesui/src/hoc/index.tsx
@@ -1,9 +1,11 @@
export * from "./CopyTooltip";
export * from "./CustomAvatar";
+export * from "./CustomCard";
export * from "./CustomDateRangePicker";
export * from "./CustomInput";
export * from "./CustomList";
export * from "./CustomSelect";
+export * from "./CustomStatistic";
export * from "./CustomTable";
export * from "./CustomTag";
export * from "./CustomTooltip";
diff --git a/clients/fidesui/src/index.ts b/clients/fidesui/src/index.ts
index 1f2846518a3..f2a9b72c921 100644
--- a/clients/fidesui/src/index.ts
+++ b/clients/fidesui/src/index.ts
@@ -239,7 +239,6 @@ export {
Badge,
Breadcrumb,
Button,
- Card,
Cascader,
Checkbox,
Col,
@@ -290,17 +289,22 @@ export type { DisplayValueType } from "rc-select/lib/BaseSelect";
// Higher-order components
export type {
CustomAvatarProps as AvatarProps,
+ CustomCardProps as CardProps,
ICustomMultiSelectProps,
ICustomSelectProps,
CustomInputProps as InputProps,
+ CustomStatisticProps as StatisticProps,
+ StatisticTrend,
} from "./hoc";
export {
CustomAvatar as Avatar,
+ CustomCard as Card,
CopyTooltip,
CustomDateRangePicker as DateRangePicker,
CustomInput as Input,
CustomList as List,
CustomSelect as Select,
+ CustomStatistic as Statistic,
CustomTable as Table,
CustomTag as Tag,
CustomTooltip as Tooltip,