diff --git a/SIP.md b/SIP.md
index bdb0a82cd93..8be321d732c 100644
--- a/SIP.md
+++ b/SIP.md
@@ -180,7 +180,22 @@ Each phase brings its own tests; the cumulative bar:
its toggle-style Edit/Preview switcher and gains a dots menu), so we
do them per-component alongside the theme wiring so each can be
reviewed in isolation.
-- _(Phase 3)_ — pending.
+- _(Phase 3)_ — ✅ landed locally. `ThemeSelectorModal` (fetches non-system
+ themes via the same `/api/v1/theme/?q=...` query that the dashboard
+ Properties modal uses; preselects the currently-resolved override;
+ "Apply" / "Cancel" / "Clear override (inherit)" buttons) and the
+ thin `setComponentThemeId(componentId, themeId | null)` action that
+ merges into `meta.themeId` via the existing `updateComponents` thunk.
+
+ No call site for the modal yet — Phase 4's per-component PRs add the
+ "Apply theme" item to each component's menu that opens this modal.
+ The modal is parent-controlled (`show`/`onHide`), parent-owned, so
+ there's no wiring needed beyond `` in each call site.
+
+ 3 passing tests on `setComponentThemeId`: preserves other meta keys
+ + sets numeric `themeId`; stores explicit `null` for the clear path;
+ no-op when the component id isn't in the layout.
- _(Phase 4)_ — pending.
### Phase 1 status
diff --git a/superset-frontend/src/dashboard/actions/setComponentThemeId.test.ts b/superset-frontend/src/dashboard/actions/setComponentThemeId.test.ts
new file mode 100644
index 00000000000..26ac622d1b6
--- /dev/null
+++ b/superset-frontend/src/dashboard/actions/setComponentThemeId.test.ts
@@ -0,0 +1,98 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { setComponentThemeId } from './setComponentThemeId';
+import { UPDATE_COMPONENTS } from './dashboardLayout';
+
+const componentFixture = {
+ id: 'CHART-abc',
+ type: 'CHART',
+ children: [],
+ parents: ['ROOT_ID', 'ROW-1'],
+ meta: {
+ chartId: 42,
+ sliceName: 'My Chart',
+ width: 4,
+ height: 30,
+ },
+};
+
+const buildState = () =>
+ ({
+ dashboardLayout: {
+ present: {
+ 'CHART-abc': componentFixture,
+ },
+ },
+ // The thunk wrapper (`setUnsavedChangesAfterAction`) reads this.
+ dashboardState: { hasUnsavedChanges: false },
+ }) as unknown as ReturnType<
+ Parameters>[1]
+ >;
+
+// `updateComponents` is wrapped by `setUnsavedChangesAfterAction`, which
+// returns a thunk. The outer dispatch receives the thunk; we recursively
+// execute it to capture the actual UPDATE_COMPONENTS action object.
+const dispatchedActions = (
+ outer: (dispatch: any, getState: any) => void,
+ getState: any,
+): any[] => {
+ const actions: any[] = [];
+ const dispatch = (action: any) => {
+ if (typeof action === 'function') {
+ action(dispatch, getState);
+ } else {
+ actions.push(action);
+ }
+ };
+ outer(dispatch, getState);
+ return actions;
+};
+
+test('dispatches an UPDATE_COMPONENTS that preserves existing meta and sets themeId', () => {
+ const actions = dispatchedActions(
+ setComponentThemeId('CHART-abc', 7),
+ () => buildState(),
+ );
+ const action = actions.find(a => a.type === UPDATE_COMPONENTS);
+ expect(action).toBeDefined();
+ expect(action.payload.nextComponents['CHART-abc'].meta).toEqual({
+ chartId: 42,
+ sliceName: 'My Chart',
+ width: 4,
+ height: 30,
+ themeId: 7,
+ });
+});
+
+test('clearing the override stores explicit null (not undefined)', () => {
+ const actions = dispatchedActions(
+ setComponentThemeId('CHART-abc', null),
+ () => buildState(),
+ );
+ const action = actions.find(a => a.type === UPDATE_COMPONENTS);
+ expect(action.payload.nextComponents['CHART-abc'].meta.themeId).toBeNull();
+});
+
+test('no-op when the component is missing from layout', () => {
+ const actions = dispatchedActions(
+ setComponentThemeId('CHART-missing', 7),
+ () => buildState(),
+ );
+ expect(actions).toEqual([]);
+});
diff --git a/superset-frontend/src/dashboard/actions/setComponentThemeId.ts b/superset-frontend/src/dashboard/actions/setComponentThemeId.ts
new file mode 100644
index 00000000000..41770c1b0b8
--- /dev/null
+++ b/superset-frontend/src/dashboard/actions/setComponentThemeId.ts
@@ -0,0 +1,52 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { AppDispatch, GetState } from 'src/dashboard/types';
+import { updateComponents } from './dashboardLayout';
+
+/**
+ * Sets (or clears) the per-component theme override on a dashboard
+ * grid component. `themeId === null` clears the override and falls back
+ * to the inherited theme.
+ *
+ * Thin wrapper around `updateComponents` that touches only the `themeId`
+ * key on the component's `meta`, preserving every other meta field. Used
+ * by `ThemeSelectorModal` (and any future call site) so the meta-merge
+ * logic lives in one place.
+ */
+export function setComponentThemeId(
+ componentId: string,
+ themeId: number | null,
+) {
+ return (dispatch: AppDispatch, getState: GetState) => {
+ const { dashboardLayout } = getState();
+ const component = dashboardLayout.present[componentId];
+ if (!component) return;
+ dispatch(
+ updateComponents({
+ [componentId]: {
+ ...component,
+ meta: {
+ ...component.meta,
+ themeId,
+ },
+ },
+ }),
+ );
+ };
+}
diff --git a/superset-frontend/src/dashboard/components/ThemeSelectorModal/index.tsx b/superset-frontend/src/dashboard/components/ThemeSelectorModal/index.tsx
new file mode 100644
index 00000000000..19ec62b2411
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/ThemeSelectorModal/index.tsx
@@ -0,0 +1,149 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useEffect, useMemo, useState } from 'react';
+import rison from 'rison';
+import { useDispatch } from 'react-redux';
+import { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { Button, Modal, Select } from '@superset-ui/core/components';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+import { useEffectiveThemeId } from 'src/dashboard/components/ComponentThemeProvider';
+import { setComponentThemeId } from 'src/dashboard/actions/setComponentThemeId';
+
+interface ThemeOption {
+ id: number;
+ theme_name: string;
+}
+
+interface ThemeSelectorModalProps {
+ /** The layout component receiving the theme override. */
+ layoutId: string;
+ /** Controls visibility. Parent owns this — toggled via menu click. */
+ show: boolean;
+ onHide: () => void;
+}
+
+/**
+ * Modal for picking a CRUD theme to apply to a single dashboard component
+ * (or clearing the existing override). On save, dispatches
+ * `setComponentThemeId`, which updates `component.meta.themeId` and marks
+ * the dashboard dirty. The actual visual application is handled by
+ * `ComponentThemeProvider`, which reads the meta change via its Redux
+ * selector and re-renders the component with the new theme tokens.
+ */
+export default function ThemeSelectorModal({
+ layoutId,
+ show,
+ onHide,
+}: ThemeSelectorModalProps) {
+ const dispatch = useDispatch();
+ const { addDangerToast } = useToasts();
+ const currentThemeId = useEffectiveThemeId(layoutId);
+
+ // Modal-local draft of the selection. Synced from the resolved id when
+ // the modal opens; only committed to Redux on save.
+ const [selectedId, setSelectedId] = useState(currentThemeId);
+ const [themes, setThemes] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ // Keep the draft in sync if the resolved id changes while the modal is
+ // open (e.g. another tab updated the dashboard). Cheap because the
+ // selector returns a primitive.
+ useEffect(() => {
+ if (show) setSelectedId(currentThemeId);
+ }, [show, currentThemeId]);
+
+ useEffect(() => {
+ if (!show) return;
+ setLoading(true);
+ // Same query the dashboard-properties modal uses — non-system themes only.
+ const q = rison.encode({
+ columns: ['id', 'theme_name'],
+ filters: [{ col: 'is_system', opr: 'eq', value: false }],
+ });
+ SupersetClient.get({ endpoint: `/api/v1/theme/?q=${q}` })
+ .then(({ json }) => {
+ setThemes((json.result as ThemeOption[]) ?? []);
+ })
+ .catch(() => {
+ addDangerToast(t('An error occurred while fetching available themes'));
+ })
+ .finally(() => setLoading(false));
+ }, [show, addDangerToast]);
+
+ const options = useMemo(
+ () => themes.map(t => ({ value: t.id, label: t.theme_name })),
+ [themes],
+ );
+
+ const handleSave = () => {
+ dispatch(setComponentThemeId(layoutId, selectedId));
+ onHide();
+ };
+
+ const handleClear = () => {
+ // Clearing the override means "inherit from parent" — store explicit
+ // null so the resolver knows it was intentional (vs absent / never set).
+ dispatch(setComponentThemeId(layoutId, null));
+ onHide();
+ };
+
+ return (
+
+ {currentThemeId !== null && (
+
+ )}
+
+
+ >
+ }
+ >
+
+ );
+}