diff --git a/app/javascript/mastodon/components/callout/callout.stories.tsx b/app/javascript/mastodon/components/callout/callout.stories.tsx
new file mode 100644
index 0000000000..f9bba1ec14
--- /dev/null
+++ b/app/javascript/mastodon/components/callout/callout.stories.tsx
@@ -0,0 +1,93 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { action } from 'storybook/actions';
+
+import { Callout } from '.';
+
+const meta = {
+ title: 'Components/Callout',
+ args: {
+ children: 'Contents here',
+ title: 'Title',
+ onPrimary: action('Primary action clicked'),
+ primaryLabel: 'Primary',
+ onSecondary: action('Secondary action clicked'),
+ secondaryLabel: 'Secondary',
+ onClose: action('Close clicked'),
+ },
+ component: Callout,
+ render(args) {
+ return (
+
+
+
+ );
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ variant: 'default',
+ },
+};
+
+export const NoIcon: Story = {
+ args: {
+ icon: false,
+ },
+};
+
+export const NoActions: Story = {
+ args: {
+ onPrimary: undefined,
+ onSecondary: undefined,
+ },
+};
+
+export const OnlyText: Story = {
+ args: {
+ onClose: undefined,
+ onPrimary: undefined,
+ onSecondary: undefined,
+ icon: false,
+ },
+};
+
+// export const Subtle: Story = {
+// args: {
+// variant: 'subtle',
+// },
+// };
+
+export const Feature: Story = {
+ args: {
+ variant: 'feature',
+ },
+};
+
+export const Inverted: Story = {
+ args: {
+ variant: 'inverted',
+ },
+};
+
+export const Success: Story = {
+ args: {
+ variant: 'success',
+ },
+};
+
+export const Warning: Story = {
+ args: {
+ variant: 'warning',
+ },
+};
+
+export const Error: Story = {
+ args: {
+ variant: 'error',
+ },
+};
diff --git a/app/javascript/mastodon/components/callout/dismissible.tsx b/app/javascript/mastodon/components/callout/dismissible.tsx
new file mode 100644
index 0000000000..70a5c850b6
--- /dev/null
+++ b/app/javascript/mastodon/components/callout/dismissible.tsx
@@ -0,0 +1,27 @@
+import { useCallback } from 'react';
+import type { FC } from 'react';
+
+import { useDismissible } from '@/mastodon/hooks/useDismissible';
+
+import { Callout } from '.';
+import type { CalloutProps } from '.';
+
+type DismissibleCalloutProps = CalloutProps & {
+ id: string;
+};
+
+export const DismissibleCallout: FC = (props) => {
+ const { dismiss, wasDismissed } = useDismissible(props.id);
+
+ const { onClose } = props;
+ const handleClose = useCallback(() => {
+ dismiss();
+ onClose?.();
+ }, [dismiss, onClose]);
+
+ if (wasDismissed) {
+ return null;
+ }
+
+ return ;
+};
diff --git a/app/javascript/mastodon/components/callout/index.tsx b/app/javascript/mastodon/components/callout/index.tsx
new file mode 100644
index 0000000000..e7ab410a9c
--- /dev/null
+++ b/app/javascript/mastodon/components/callout/index.tsx
@@ -0,0 +1,150 @@
+import type { FC, ReactNode } from 'react';
+
+import { useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+
+import CheckIcon from '@/material-icons/400-24px/check.svg?react';
+import CloseIcon from '@/material-icons/400-24px/close.svg?react';
+import ErrorIcon from '@/material-icons/400-24px/error.svg?react';
+import InfoIcon from '@/material-icons/400-24px/info.svg?react';
+import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
+
+import type { IconProp } from '../icon';
+import { Icon } from '../icon';
+import { IconButton } from '../icon_button';
+
+import classes from './styles.module.css';
+
+export interface CalloutProps {
+ variant?:
+ | 'default'
+ // | 'subtle'
+ | 'feature'
+ | 'inverted'
+ | 'success'
+ | 'warning'
+ | 'error';
+ title?: ReactNode;
+ children: ReactNode;
+ className?: string;
+ /** Set to false to hide the icon. */
+ icon?: IconProp | boolean;
+ onPrimary?: () => void;
+ primaryLabel?: string;
+ onSecondary?: () => void;
+ secondaryLabel?: string;
+ onClose?: () => void;
+ id?: string;
+}
+
+const variantClasses = {
+ default: classes.variantDefault as string,
+ // subtle: classes.variantSubtle as string,
+ feature: classes.variantFeature as string,
+ inverted: classes.variantInverted as string,
+ success: classes.variantSuccess as string,
+ warning: classes.variantWarning as string,
+ error: classes.variantError as string,
+} as const;
+
+export const Callout: FC = ({
+ className,
+ variant = 'default',
+ title,
+ children,
+ icon,
+ onPrimary: primaryAction,
+ primaryLabel,
+ onSecondary: secondaryAction,
+ secondaryLabel,
+ onClose,
+ id,
+}) => {
+ const intl = useIntl();
+
+ return (
+
+ );
+};
+
+const CalloutIcon: FC> = ({
+ variant = 'default',
+ icon,
+}) => {
+ if (icon === false) {
+ return null;
+ }
+
+ if (!icon || icon === true) {
+ switch (variant) {
+ case 'inverted':
+ case 'success':
+ icon = CheckIcon;
+ break;
+ case 'warning':
+ icon = WarningIcon;
+ break;
+ case 'error':
+ icon = ErrorIcon;
+ break;
+ default:
+ icon = InfoIcon;
+ }
+ }
+
+ return ;
+};
diff --git a/app/javascript/mastodon/components/callout/styles.module.css b/app/javascript/mastodon/components/callout/styles.module.css
new file mode 100644
index 0000000000..5e6e28d122
--- /dev/null
+++ b/app/javascript/mastodon/components/callout/styles.module.css
@@ -0,0 +1,125 @@
+.wrapper {
+ display: flex;
+ align-items: start;
+ padding: 12px;
+ gap: 8px;
+ background-color: var(--color-bg-brand-softer);
+ color: var(--color-text-primary);
+ border-radius: 12px;
+}
+
+.icon {
+ padding: 4px;
+ border-radius: 9999px;
+ width: 1rem;
+ height: 1rem;
+}
+
+.content {
+ display: flex;
+ gap: 8px;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+@media screen and (width >= 630px) {
+ .content {
+ flex-direction: row;
+ }
+}
+
+.icon + .content,
+.wrapper:has(.close) .content {
+ margin-top: 2px;
+}
+
+.body {
+ flex-grow: 1;
+
+ h3 {
+ font-weight: 500;
+ }
+}
+
+.actionWrapper {
+ display: flex;
+ gap: 8px;
+ align-items: start;
+}
+
+.action {
+ appearance: none;
+ background: none;
+ border: none;
+ color: inherit;
+ font-weight: 500;
+ padding: 0;
+ text-decoration: underline;
+ transition: color 0.1s ease-in-out;
+
+ &:hover {
+ color: var(--color-text-brand-soft);
+ }
+}
+
+.close {
+ color: inherit;
+
+ svg {
+ width: 20px;
+ height: 20px;
+ }
+}
+
+.variantDefault {
+ .icon {
+ background-color: var(--color-bg-brand-soft);
+ }
+}
+
+/* .variantSubtle {
+ border: 1px solid var(--color-bg-brand-softer);
+ background-color: var(--color-bg-primary);
+
+ .icon {
+ background-color: var(--color-bg-brand-softer);
+ }
+} */
+
+.variantFeature {
+ background-color: var(--color-bg-brand-base);
+ color: var(--color-text-on-brand-base);
+
+ button:hover {
+ color: color-mix(var(--color-text-on-brand-base), transparent 20%);
+ }
+}
+
+.variantInverted {
+ background-color: var(--color-bg-inverted);
+ color: var(--color-text-on-inverted);
+}
+
+.variantSuccess {
+ background-color: var(--color-bg-success-softer);
+
+ .icon {
+ background-color: var(--color-bg-success-soft);
+ }
+}
+
+.variantWarning {
+ background-color: var(--color-bg-warning-softer);
+
+ .icon {
+ background-color: var(--color-bg-warning-soft);
+ }
+}
+
+.variantError {
+ background-color: var(--color-bg-error-softer);
+
+ .icon {
+ background-color: var(--color-bg-error-soft);
+ }
+}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 3020e0d34e..72ab261702 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -187,6 +187,7 @@
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this screen.",
"bundle_modal_error.retry": "Try again",
+ "callout.dismiss": "Dismiss",
"carousel.current": "Slide {current, number} / {max, number}",
"carousel.slide": "Slide {current, number} of {max, number}",
"closed_registrations.other_server_instructions": "Since Mastodon is decentralized, you can create an account on another server and still interact with this one.",
diff --git a/app/javascript/material-icons/400-24px/error-fill.svg b/app/javascript/material-icons/400-24px/error-fill.svg
new file mode 100644
index 0000000000..5125e9acce
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/error-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/error.svg b/app/javascript/material-icons/400-24px/error.svg
new file mode 100644
index 0000000000..86c4555326
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/error.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/eslint.config.mjs b/eslint.config.mjs
index a9dc9732bf..c7ba755d67 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -399,6 +399,7 @@ export default tseslint.config([
allowNumber: true,
},
],
+ '@typescript-eslint/non-nullable-type-assertion-style': 'off',
},
},
{