diff --git a/knip.config.ts b/knip.config.ts
index 4beec37cc224d2..4f7f9fe864b73f 100644
--- a/knip.config.ts
+++ b/knip.config.ts
@@ -27,6 +27,8 @@ const productionEntryPoints = [
'static/app/views/seerExplorer/contexts/**/*.{js,ts,tsx}',
// TODO: Remove when wired into the connect repository modal
'static/app/components/connectRepository/**/*.{ts,tsx}',
+ // TODO: Remove when consumed in production (#117849 wires it into alert frequency)
+ 'static/app/views/onboarding/components/scmCollapsibleSection.tsx',
];
const testingEntryPoints = [
diff --git a/static/app/views/onboarding/components/scmCollapsibleSection.spec.tsx b/static/app/views/onboarding/components/scmCollapsibleSection.spec.tsx
new file mode 100644
index 00000000000000..3dd72301defd7a
--- /dev/null
+++ b/static/app/views/onboarding/components/scmCollapsibleSection.spec.tsx
@@ -0,0 +1,55 @@
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {ScmCollapsibleSection} from './scmCollapsibleSection';
+
+describe('ScmCollapsibleSection', () => {
+ it('renders the title and content expanded by default', () => {
+ render(
+
+ Body content
+
+ );
+
+ expect(screen.getByRole('button', {name: 'Section title'})).toBeInTheDocument();
+ expect(screen.getByText('Body content')).toBeInTheDocument();
+ });
+
+ it('starts collapsed when defaultExpanded is false', () => {
+ render(
+
+ Body content
+
+ );
+
+ expect(screen.queryByText('Body content')).not.toBeInTheDocument();
+ });
+
+ it('toggles the content when the title is clicked', async () => {
+ render(
+
+ Body content
+
+ );
+
+ const toggle = screen.getByRole('button', {name: 'Section title'});
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
+
+ await userEvent.click(toggle);
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.queryByText('Body content')).not.toBeInTheDocument();
+
+ await userEvent.click(toggle);
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText('Body content')).toBeInTheDocument();
+ });
+
+ it('renders trailing content in the header', () => {
+ render(
+ Trailing}>
+ Body content
+
+ );
+
+ expect(screen.getByText('Trailing')).toBeInTheDocument();
+ });
+});
diff --git a/static/app/views/onboarding/components/scmCollapsibleSection.tsx b/static/app/views/onboarding/components/scmCollapsibleSection.tsx
new file mode 100644
index 00000000000000..bd2839463000c7
--- /dev/null
+++ b/static/app/views/onboarding/components/scmCollapsibleSection.tsx
@@ -0,0 +1,101 @@
+import {useId, useState} from 'react';
+import styled from '@emotion/styled';
+import {AnimatePresence, motion} from 'framer-motion';
+
+import {Button} from '@sentry/scraps/button';
+import {Flex, Stack} from '@sentry/scraps/layout';
+import {Text} from '@sentry/scraps/text';
+
+import {IconChevron} from 'sentry/icons';
+
+interface ScmCollapsibleSectionProps {
+ children: React.ReactNode;
+ title: React.ReactNode;
+ /**
+ * Whether the section starts expanded. Defaults to true.
+ */
+ defaultExpanded?: boolean;
+ /**
+ * Rendered at the far right of the title row (e.g. a helper label). Stays in
+ * the header whether or not the section is expanded.
+ */
+ trailing?: React.ReactNode;
+}
+
+/**
+ * A collapsible section for the SCM project-creation flow: a chevron and the
+ * title share one transparent toggle button (mirroring the core Disclosure
+ * look) with an optional trailing slot pinned right, and the body animates its
+ * own height so sibling cards in a framer-motion `layout="position"` group
+ * follow via normal document flow. `initial={false}` keeps it from animating on
+ * mount, so it renders in its `defaultExpanded` state.
+ *
+ * This is a local variant of the core Disclosure rather than a consumer of it:
+ * Disclosure.Content hides with `display: none`, which can't tween and won't
+ * reflow sibling cards, and Disclosure.Title's full-width stretched button
+ * can't express a content-hugging toggle without forking the shared component.
+ */
+export function ScmCollapsibleSection({
+ title,
+ trailing,
+ defaultExpanded = true,
+ children,
+}: ScmCollapsibleSectionProps) {
+ const [expanded, setExpanded] = useState(defaultExpanded);
+ const contentId = useId();
+
+ return (
+
+
+ }
+ aria-expanded={expanded}
+ // Only reference the content while it is in the DOM: the body is
+ // conditionally rendered, so a static aria-controls would point at a
+ // missing IDREF when collapsed.
+ aria-controls={expanded ? contentId : undefined}
+ onClick={() => setExpanded(value => !value)}
+ >
+
+ {title}
+
+
+ {trailing}
+
+
+ {expanded && (
+
+ {children}
+
+ )}
+
+
+ );
+}
+
+// Mirrors core Disclosure's StretchedButton: a transparent toggle holding the
+// chevron + title that hugs its content, with the left padding pulled in so the
+// chevron sits near-flush with the section edge.
+const ToggleButton = styled(Button)`
+ padding-left: ${p => p.theme.space.xs};
+`;
+
+// Indents the body so its left edge lines up with the title copy inside
+// ToggleButton: button padding-left (xs) + chevron width (md button -> sm icon,
+// 14px) + the button's icon gap (md). Matches core Disclosure's 26px inset.
+// padding-top lives here (not as a Stack gap) so the spacing collapses with the
+// height animation instead of leaving a gap behind the title.
+const Content = styled(Stack)`
+ padding-top: ${p => p.theme.space.md};
+ padding-left: calc(${p => p.theme.space.xs} + 14px + ${p => p.theme.space.md});
+`;