diff --git a/app/javascript/mastodon/components/modal_shell/redesign.module.scss b/app/javascript/mastodon/components/modal_shell/redesign.module.scss
new file mode 100644
index 00000000000..01f99a914f0
--- /dev/null
+++ b/app/javascript/mastodon/components/modal_shell/redesign.module.scss
@@ -0,0 +1,38 @@
+@use '@/styles/mastodon/mixins';
+
+.modal {
+ @include mixins.elevation-1;
+
+ border-radius: var(--radius-xl);
+ background: var(--color-bg-primary);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+ padding: var(--space-md);
+ pointer-events: auto;
+ user-select: text;
+ overflow: hidden;
+}
+
+.modalElevated {
+ @include mixins.elevation-2;
+}
+
+.title {
+ @include mixins.type-heading-sm;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-md);
+}
+
+.actionsLeft {
+ justify-content: flex-start;
+}
+
+.actionsRight {
+ justify-content: flex-end;
+}
diff --git a/app/javascript/mastodon/components/modal_shell/redesign.stories.tsx b/app/javascript/mastodon/components/modal_shell/redesign.stories.tsx
new file mode 100644
index 00000000000..ee17187fb1c
--- /dev/null
+++ b/app/javascript/mastodon/components/modal_shell/redesign.stories.tsx
@@ -0,0 +1,57 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { Button } from '../button/redesign';
+
+import type { ModalShellProps } from './redesign';
+import { ModalShell, ModalActions, ModalTitle } from './redesign';
+
+const meta = {
+ title: 'Redesign/Modal',
+ args: {
+ actionAlign: 'stretch',
+ children: 'This is a modal.',
+ elevation: 2,
+ maxWidth: 0,
+ },
+ argTypes: {
+ actionAlign: {
+ control: 'inline-radio',
+ options: ['left', 'right', 'stretch'],
+ },
+ elevation: {
+ control: 'inline-radio',
+ options: [1, 2],
+ },
+ },
+ render({ actionAlign, children, ...props }) {
+ return (
+
+ Modal Example
+
+ {children
+ .split('\n')
+ .filter((line) => !!line.trim())
+ .map((line, index) => (
+ {line}
+ ))}
+
+
+
+
+
+
+
+ );
+ },
+} satisfies Meta<
+ Pick & {
+ children: string;
+ actionAlign?: 'left' | 'right' | 'stretch';
+ }
+>;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/app/javascript/mastodon/components/modal_shell/redesign.tsx b/app/javascript/mastodon/components/modal_shell/redesign.tsx
new file mode 100644
index 00000000000..e224cae091b
--- /dev/null
+++ b/app/javascript/mastodon/components/modal_shell/redesign.tsx
@@ -0,0 +1,101 @@
+import type React from 'react';
+
+import classNames from 'classnames';
+
+import type { PolymorphicProps } from '@/types/polymorphic';
+
+import { NavigationFocusTarget } from '../navigation_focus_target';
+
+import classes from './redesign.module.scss';
+
+export interface ModalShellProps {
+ children: React.ReactNode;
+ className?: string;
+ elevation?: 1 | 2;
+ maxWidth?: number;
+ style?: React.CSSProperties;
+}
+
+export const ModalShell = ({
+ as: asComp,
+ elevation,
+ className,
+ children,
+ maxWidth,
+ style,
+ ...props
+}: PolymorphicProps) => {
+ const Comp = asComp ?? 'div';
+ return (
+
+ {children}
+
+ );
+};
+
+type HeadingLevels = 1 | 2 | 3 | 4 | 5 | 6;
+
+type ModalTitleProps = { children: React.ReactNode; level?: HeadingLevels } & (
+ | { noFocus: true }
+ | { noFocus?: false; focusTargetName?: string }
+);
+
+export const ModalTitle: React.FC<
+ ModalTitleProps & React.ComponentPropsWithRef<`h${HeadingLevels}`>
+> = ({ level = 1, children, className, noFocus, ...props }) => {
+ const Header = `h${level}` as const;
+ if (!noFocus) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ );
+};
+
+export const ModalActions = ({
+ align,
+ as: asComp,
+ children,
+ className,
+ ...props
+}: PolymorphicProps<
+ {
+ align?: 'left' | 'right' | 'stretch';
+ children?: React.ReactNode;
+ className?: string;
+ },
+ As
+>) => {
+ const Comp = asComp ?? 'div';
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/app/javascript/types/polymorphic.ts b/app/javascript/types/polymorphic.ts
index e58aa7b75ee..32c948b992f 100644
--- a/app/javascript/types/polymorphic.ts
+++ b/app/javascript/types/polymorphic.ts
@@ -38,7 +38,7 @@ type ElementRef =
* Merge additional props with intrinsic/element props for `as`.
* Additional props win on conflicts.
*/
-type PolymorphicProps<
+type OldPolymorphicProps<
As extends ElementType,
AdditionalProps extends object = object,
> = AdditionalProps &
@@ -52,7 +52,9 @@ type PolymorphicWithRef<
DefaultAs extends ElementType,
AdditionalProps extends object = object,
> = (
- props: PolymorphicProps & { ref?: Ref> },
+ props: OldPolymorphicProps & {
+ ref?: Ref>;
+ },
) => ReactElement | null;
/**
@@ -64,12 +66,22 @@ type PolyRefFunction = <
>(
render: ForwardRefRenderFunction<
ElementRef,
- PolymorphicProps
+ OldPolymorphicProps
>,
) => PolymorphicWithRef &
- ForwardRefExoticComponent>;
+ ForwardRefExoticComponent>;
/**
* Polymorphic `forwardRef` function.
*/
export const polymorphicForwardRef = forwardRef as PolyRefFunction;
+
+// Use this for modern React 19 props
+
+export type PolymorphicProps<
+ AdditionalProps extends object,
+ As extends React.ElementType,
+> = {
+ as?: As;
+} & Omit, keyof AdditionalProps | 'as'> &
+ AdditionalProps;