diff --git a/README.md b/README.md
index 030a3b95f7..111b375d89 100644
--- a/README.md
+++ b/README.md
@@ -141,6 +141,7 @@ import Button from '@leafygreen-ui/button';
| [@leafygreen-ui/toolbar](./packages/toolbar) | [](https://www.npmjs.com/package/@leafygreen-ui/toolbar) |  | [Live Example](http://mongodb.design/component/toolbar/live-example) |
| [@leafygreen-ui/tooltip](./packages/tooltip) | [](https://www.npmjs.com/package/@leafygreen-ui/tooltip) |  | [Live Example](http://mongodb.design/component/tooltip/live-example) |
| [@leafygreen-ui/typography](./packages/typography) | [](https://www.npmjs.com/package/@leafygreen-ui/typography) |  | [Live Example](http://mongodb.design/component/typography/live-example) |
+| [@leafygreen-ui/vertical-stepper](./packages/vertical-stepper) | [](https://www.npmjs.com/package/@leafygreen-ui/vertical-stepper) |  | [Live Example](http://mongodb.design/component/vertical-stepper/live-example) |
| [@lg-charts/chart-card](./charts/chart-card) | [](https://www.npmjs.com/package/@lg-charts/chart-card) |  | [Live Example](http://mongodb.design/component/chart-card/live-example) |
| [@lg-charts/colors](./charts/colors) | [](https://www.npmjs.com/package/@lg-charts/colors) |  | [Live Example](http://mongodb.design/component/colors/live-example) |
| [@lg-charts/core](./charts/core) | [](https://www.npmjs.com/package/@lg-charts/core) |  | [Live Example](http://mongodb.design/component/core/live-example) |
diff --git a/packages/vertical-stepper/CHANGELOG.md b/packages/vertical-stepper/CHANGELOG.md
new file mode 100644
index 0000000000..384d4b2c88
--- /dev/null
+++ b/packages/vertical-stepper/CHANGELOG.md
@@ -0,0 +1,45 @@
+# @leafygreen-ui/vertical-stepper
+
+## 3.0.0
+
+### Major Changes
+
+- **Package has been renamed and moved!** This package is now published under the `@leafygreen-ui` scope. All dependencies and import paths must be updated from `@lg-private/vertical-stepper` to `@leafygreen-ui/vertical-stepper`.
+
+### Major Changes
+
+- c9203f7: Removes `prop-types`. Updates LG core packages to latest
+
+## 2.1.1
+
+### Patch Changes
+
+- 066c4ce: [LG-4670](https://jira.mongodb.org/browse/LG-4670): vertically stack buttons in `VerticalStepActions` on smaller breakpoints
+
+## 2.1.0
+
+### Minor Changes
+
+- dcab77e: [LG-4413](https://jira.mongodb.org/browse/LG-4413): VerticalStepper steps always render description and media regardless of current step
+
+## 2.0.0
+
+### Major Changes
+
+- a3f1aa9: Replaces `primaryButtonProps` and `secondaryButtonProps` with `actions` prop to enable more flexibility for CTAs in each step
+
+## 1.1.0
+
+### Minor Changes
+
+- f78419b: [LG-4395](https://jira.mongodb.org/browse/LG-4395): Fixes buttons sizes from default to small
+
+### Patch Changes
+
+- 8f076fb: Fixes naming of lg private packages from `@leafygreen-ui/*` to `@lg-private/*`
+
+## 1.0.0
+
+### Major Changes
+
+- d50a214: First major release of `VerticalStepper`. [LG-4333](https://jira.mongodb.org/browse/LG-4333)
diff --git a/packages/vertical-stepper/README.md b/packages/vertical-stepper/README.md
new file mode 100644
index 0000000000..827c037b34
--- /dev/null
+++ b/packages/vertical-stepper/README.md
@@ -0,0 +1,113 @@
+# Vertical Stepper
+
+
+
+#### [View on MongoDB.design](https://www.mongodb.design/component/vertical-stepper/example/)
+
+## Installation
+
+### Yarn
+
+```shell
+yarn add @leafygreen-ui/vertical-stepper
+```
+
+### NPM
+
+```shell
+npm install @leafygreen-ui/vertical-stepper
+```
+
+## Example
+
+```js
+import Button, { Size, Variant } from `@leafygreen-ui/button`;
+import { VerticalStepper, VerticalStep } from `@leafygreen-ui/vertical-stepper`;
+
+const [currentStep, setCurrentStep] = useState(0);
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam
+ efficitur nunc mattis magna pretium, id mattis metus vestibulum. Integer
+ cursus ex ante, ut molestie lorem vestibulum id.{' '}
+ Im a link
+ >
+ }
+ actions={
+
+ }
+ />
+
+
+
+ >
+ }
+ media={
}
+ />
+
+
+
+ >
+ }
+ media={
}
+ />
+;
+```
+
+## Properties
+
+### ``
+
+| Prop | Type | Description | Default |
+| ----------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
+| darkMode | `boolean` | Determines if the component renders in dark mode | `false` |
+| currentStep | `number` | Zero-based. The index of the current step that will appear active. All steps will be marked as completed if the currentStep equals the number of steps. | `0` |
+| children | `string` | Two or more `` components | |
+
+### ``
+
+| Prop | Type | Description | Default |
+| ----------- | ----------------- | --------------------------------------------------------------- | ------- |
+| title | `string` | The title of the step. | |
+| description | `React.ReactNode` | The description of the step. This will render below the title. | |
+| media | `React.ReactNode` | The image to the right of the text. E.g. `
` or `` | |
+| actions | `React.ReactNode` | Optional buttons that will render below the text. | |
diff --git a/packages/vertical-stepper/package.json b/packages/vertical-stepper/package.json
new file mode 100644
index 0000000000..3ff801e128
--- /dev/null
+++ b/packages/vertical-stepper/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@leafygreen-ui/vertical-stepper",
+ "version": "3.0.0",
+ "description": "LeafyGreen UI Kit Vertical Stepper",
+ "main": "./dist/umd/index.js",
+ "module": "./dist/esm/index.js",
+ "types": "./dist/types/index.d.ts",
+ "exports": {
+ ".": {
+ "require": "./dist/umd/index.js",
+ "import": "./dist/esm/index.js",
+ "types": "./dist/types/index.d.ts"
+ }
+ },
+ "license": "Apache-2.0",
+ "scripts": {
+ "build": "lg build-package",
+ "tsc": "lg build-ts",
+ "docs": "lg build-tsdoc"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "dependencies": {
+ "@leafygreen-ui/button": "workspace:^",
+ "@leafygreen-ui/descendants": "workspace:^",
+ "@leafygreen-ui/emotion": "workspace:^",
+ "@leafygreen-ui/icon": "workspace:^",
+ "@leafygreen-ui/lib": "workspace:^",
+ "@leafygreen-ui/palette": "workspace:^",
+ "@leafygreen-ui/tokens": "workspace:^",
+ "@leafygreen-ui/typography": "workspace:^"
+ },
+ "peerDependencies": {
+ "@leafygreen-ui/leafygreen-provider": "workspace:^"
+ },
+ "devDependencies": {
+ "@lg-tools/build": "workspace:^",
+ "@lg-tools/storybook-utils": "workspace:^"
+ },
+ "homepage": "https://github.com/10gen/leafygreen-ui/tree/main/packages/vertical-stepper",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/10gen/leafygreen-ui"
+ },
+ "bugs": {
+ "url": "https://jira.mongodb.org/projects/PD/summary"
+ }
+}
diff --git a/packages/vertical-stepper/src/StepIcon/StepIcon.styles.ts b/packages/vertical-stepper/src/StepIcon/StepIcon.styles.ts
new file mode 100644
index 0000000000..10cde3af6f
--- /dev/null
+++ b/packages/vertical-stepper/src/StepIcon/StepIcon.styles.ts
@@ -0,0 +1,82 @@
+import { css } from '@leafygreen-ui/emotion';
+import { createUniqueClassName, Theme } from '@leafygreen-ui/lib';
+import { palette } from '@leafygreen-ui/palette';
+import { transitionDuration } from '@leafygreen-ui/tokens';
+
+import { State } from '../VerticalStep/VerticalStep.types';
+
+export const stepIconClassName = createUniqueClassName('step');
+
+const STEP_SIZE = 20;
+
+export const getStepWrapperStyles = (isCompleted: boolean) => css`
+ position: relative;
+
+ &::after {
+ background: ${isCompleted ? palette.green.dark1 : palette.gray.base};
+ position: absolute;
+ width: 1px;
+ height: calc(100% - ${STEP_SIZE}px);
+ left: 50%;
+ transition: background ${transitionDuration.default}ms ease;
+ }
+`;
+
+export const stepStyles = css`
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid;
+ transition: ${transitionDuration.default}ms ease;
+ width: ${STEP_SIZE}px;
+ height: ${STEP_SIZE}px;
+ position: relative;
+ font-size: 12px;
+ font-weight: 500;
+`;
+
+export const themedStateColor = {
+ [Theme.Dark]: {
+ [State.Future]: palette.gray.light1,
+ [State.Completed]: palette.black,
+ [State.Current]: palette.green.base,
+ },
+ [Theme.Light]: {
+ [State.Future]: palette.gray.dark1,
+ [State.Completed]: palette.white,
+ [State.Current]: palette.green.dark2,
+ },
+};
+
+export const themedStateBgColor = {
+ [Theme.Dark]: {
+ [State.Future]: 'rgba(255, 255, 255, 0)',
+ [State.Completed]: palette.green.base,
+ [State.Current]: 'rgba(255, 255, 255, 0)',
+ },
+ [Theme.Light]: {
+ [State.Future]: 'rgba(255, 255, 255, 0)',
+ [State.Completed]: palette.green.dark1,
+ [State.Current]: 'rgba(255, 255, 255, 0)',
+ },
+};
+
+export const themedStateBorderColor = {
+ [Theme.Dark]: {
+ [State.Future]: palette.gray.light1,
+ [State.Completed]: palette.green.base,
+ [State.Current]: palette.green.base,
+ },
+ [Theme.Light]: {
+ [State.Future]: palette.gray.base,
+ [State.Completed]: palette.green.dark1,
+ [State.Current]: palette.green.dark1,
+ },
+};
+
+export const getThemedStateStyles = (theme: Theme, state: State) => css`
+ color: ${themedStateColor[theme][state]};
+ background-color: ${themedStateBgColor[theme][state]};
+ border-color: ${themedStateBorderColor[theme][state]};
+`;
diff --git a/packages/vertical-stepper/src/StepIcon/StepIcon.tsx b/packages/vertical-stepper/src/StepIcon/StepIcon.tsx
new file mode 100644
index 0000000000..563c8700c1
--- /dev/null
+++ b/packages/vertical-stepper/src/StepIcon/StepIcon.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+
+import { cx } from '@leafygreen-ui/emotion';
+import CheckmarkIcon from '@leafygreen-ui/icon/dist/Checkmark';
+import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
+
+import {
+ getStepWrapperStyles,
+ getThemedStateStyles,
+ stepIconClassName,
+ stepStyles,
+} from './StepIcon.styles';
+import { StepIconProps } from './StepIcon.types';
+
+/**
+ *
+ * @internal
+ */
+export const StepIcon = ({ isCompleted, state, index }: StepIconProps) => {
+ const { theme } = useDarkMode();
+
+ return (
+
+
+ {isCompleted ? : index + 1}
+
+
+ );
+};
+
+StepIcon.displayName = 'StepIcon';
diff --git a/packages/vertical-stepper/src/StepIcon/StepIcon.types.ts b/packages/vertical-stepper/src/StepIcon/StepIcon.types.ts
new file mode 100644
index 0000000000..29a8ceeff7
--- /dev/null
+++ b/packages/vertical-stepper/src/StepIcon/StepIcon.types.ts
@@ -0,0 +1,11 @@
+import { InternalVerticalStepProps } from '../VerticalStep';
+
+export type StepIconProps = Pick<
+ InternalVerticalStepProps,
+ 'index' | 'state'
+> & {
+ /**
+ * Whether the step is completed
+ */
+ isCompleted: boolean;
+};
diff --git a/packages/vertical-stepper/src/StepIcon/index.ts b/packages/vertical-stepper/src/StepIcon/index.ts
new file mode 100644
index 0000000000..1d4011b03a
--- /dev/null
+++ b/packages/vertical-stepper/src/StepIcon/index.ts
@@ -0,0 +1,2 @@
+export { StepIcon } from './StepIcon';
+export { stepIconClassName } from './StepIcon.styles';
diff --git a/packages/vertical-stepper/src/VerticalStep/InternalVerticalStep.tsx b/packages/vertical-stepper/src/VerticalStep/InternalVerticalStep.tsx
new file mode 100644
index 0000000000..aa4ca72a3e
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStep/InternalVerticalStep.tsx
@@ -0,0 +1,100 @@
+import React from 'react';
+
+import { cx } from '@leafygreen-ui/emotion';
+import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
+import { BaseFontSize } from '@leafygreen-ui/tokens';
+import { Body, Description } from '@leafygreen-ui/typography';
+
+import { LGIDS_VERTICAL_STEPPER } from '../constants';
+import { StepIcon } from '../StepIcon';
+import { VerticalStepActions } from '../VerticalStepActions';
+
+import {
+ baseStyles,
+ contentClassName,
+ getContentStyles,
+ getTitleStyles,
+ getWrapperStyles,
+ mediaStyles,
+} from './VerticalStep.styles';
+import { InternalVerticalStepProps, State } from './VerticalStep.types';
+
+/**
+ * @internal
+ */
+
+export const InternalVerticalStep = React.forwardRef<
+ HTMLLIElement,
+ InternalVerticalStepProps
+>(
+ (
+ {
+ title,
+ description,
+ media,
+ actions,
+ state,
+ index,
+ className,
+ ...rest
+ }: InternalVerticalStepProps,
+ forwardRef,
+ ) => {
+ const { theme } = useDarkMode();
+
+ const isCompleted = state === State.Completed;
+ const isCurrent = state === State.Current;
+ const hasActions = actions !== undefined;
+
+ return (
+
+
+
+
+ {title}
+
+
+
+ {description}
+
+ {media && (
+
+ {media}
+
+ )}
+ {hasActions && (
+
+ )}
+
+
+
+ );
+ },
+);
+
+InternalVerticalStep.displayName = 'InternalVerticalStep';
diff --git a/packages/vertical-stepper/src/VerticalStep/VerticalStep.spec.tsx b/packages/vertical-stepper/src/VerticalStep/VerticalStep.spec.tsx
new file mode 100644
index 0000000000..ab4bfb30f3
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStep/VerticalStep.spec.tsx
@@ -0,0 +1,168 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import Button from '@leafygreen-ui/button';
+
+import { LGIDS_VERTICAL_STEPPER } from '../constants';
+
+import { State } from './VerticalStep.types';
+import {
+ InternalVerticalStep,
+ InternalVerticalStepProps,
+ VerticalStep,
+} from '.';
+
+const titleText = 'This is the title';
+const descriptionText = 'This is the description';
+
+const renderVerticalStep = ({
+ title = titleText,
+ description = descriptionText,
+ state = State.Current,
+ ...props
+}: Partial) => {
+ const utils = render(
+ ,
+ );
+
+ const verticalStep = utils.getByTestId(LGIDS_VERTICAL_STEPPER.step);
+
+ return {
+ ...utils,
+ verticalStep,
+ };
+};
+
+describe('packages/vertical-step', () => {
+ describe('step', () => {
+ describe('rendering', () => {
+ test('title renders', () => {
+ const { getByTestId } = renderVerticalStep({});
+ const title = getByTestId(LGIDS_VERTICAL_STEPPER.stepTitle);
+ expect(title).toBeInTheDocument();
+ expect(title).toHaveTextContent(titleText);
+ });
+ test('description renders', () => {
+ const { getByTestId } = renderVerticalStep({});
+ const description = getByTestId(LGIDS_VERTICAL_STEPPER.stepDescription);
+ expect(description).toBeInTheDocument();
+ expect(description).toHaveTextContent(descriptionText);
+ });
+
+ describe('media', () => {
+ test('renders', () => {
+ const { getByTestId } = renderVerticalStep({
+ media:
,
+ });
+ const media = getByTestId(LGIDS_VERTICAL_STEPPER.stepMedia);
+ expect(media).toBeInTheDocument();
+ });
+ test('does not render', () => {
+ const { queryByTestId } = renderVerticalStep({});
+ const media = queryByTestId(LGIDS_VERTICAL_STEPPER.stepMedia);
+ expect(media).not.toBeInTheDocument();
+ });
+ });
+
+ describe('actions', () => {
+ test('render', () => {
+ const { getByTestId } = renderVerticalStep({
+ actions: ,
+ });
+ const actions = getByTestId(LGIDS_VERTICAL_STEPPER.stepActions);
+ expect(actions).toBeInTheDocument();
+ expect(actions).toHaveTextContent('primary button');
+ });
+ test('does not render', () => {
+ const { queryByTestId } = renderVerticalStep({});
+ const actions = queryByTestId(LGIDS_VERTICAL_STEPPER.stepActions);
+ expect(actions).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('current', () => {
+ test('aria-current is "step"', async () => {
+ const { verticalStep } = renderVerticalStep({});
+ expect(verticalStep).toHaveAttribute('data-state', State.Current);
+ expect(verticalStep).toHaveAttribute('aria-current', 'step');
+ });
+
+ test('buttons are tabbable', async () => {
+ const buttonTestId = 'test-button';
+ const { getByTestId } = renderVerticalStep({
+ actions: ,
+ });
+ const button = getByTestId(buttonTestId);
+ userEvent.tab();
+ expect(button).toHaveFocus();
+ });
+ });
+
+ describe('completed', () => {
+ test('aria-current is false', async () => {
+ const { verticalStep } = renderVerticalStep({
+ state: State.Completed,
+ });
+ expect(verticalStep).toHaveAttribute('data-state', State.Completed);
+ expect(verticalStep).toHaveAttribute('aria-current', 'false');
+ });
+
+ test('buttons are not tabbable', async () => {
+ const { getByTestId } = renderVerticalStep({
+ state: State.Completed,
+ actions: ,
+ });
+ const actions = getByTestId(LGIDS_VERTICAL_STEPPER.stepActions);
+ // should test with .not.toHaveFocus() but jsdom does not currently support the `inert` attribute: https://github.com/jsdom/jsdom/issues/3605
+ expect(actions).toHaveAttribute('inert');
+ });
+ });
+
+ describe('future', () => {
+ test('aria-current is false', async () => {
+ const { verticalStep } = renderVerticalStep({
+ state: State.Future,
+ });
+ expect(verticalStep).toHaveAttribute('data-state', State.Future);
+ expect(verticalStep).toHaveAttribute('aria-current', 'false');
+ });
+
+ test('buttons are not tabbable', async () => {
+ const { getByTestId } = renderVerticalStep({
+ state: State.Future,
+ actions: ,
+ });
+ const actions = getByTestId(LGIDS_VERTICAL_STEPPER.stepActions);
+ // should test with .not.toHaveFocus() but jsdom does not currently support the `inert` attribute: https://github.com/jsdom/jsdom/issues/3605
+ expect(actions).toHaveAttribute('inert');
+ });
+ });
+ });
+
+ describe('TypeScript types are correct', () => {
+ // eslint-disable-next-line jest/no-disabled-tests
+ test.skip('VerticalStepper component types', () => {
+ <>
+
+
+ {/* @ts-expect-error Missing title and description */}
+
+
+ }
+ actions={}
+ />
+ >;
+ });
+ });
+});
diff --git a/packages/vertical-stepper/src/VerticalStep/VerticalStep.styles.ts b/packages/vertical-stepper/src/VerticalStep/VerticalStep.styles.ts
new file mode 100644
index 0000000000..dd31a31b9c
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStep/VerticalStep.styles.ts
@@ -0,0 +1,111 @@
+import { css } from '@leafygreen-ui/emotion';
+import { createUniqueClassName, Theme } from '@leafygreen-ui/lib';
+import { palette } from '@leafygreen-ui/palette';
+import {
+ breakpoints,
+ fontWeights,
+ spacing,
+ transitionDuration,
+ typeScales,
+} from '@leafygreen-ui/tokens';
+
+import { stepIconClassName } from '../StepIcon';
+
+import { State } from './VerticalStep.types';
+
+export const contentClassName = createUniqueClassName('content');
+
+export const baseStyles = css`
+ display: flex;
+ gap: ${spacing[200]}px;
+
+ // This adds the line between steps
+ &:not(:last-of-type) {
+ .${stepIconClassName} {
+ &::after {
+ content: '';
+ }
+ }
+ }
+
+ &:last-of-type {
+ .${contentClassName} {
+ margin: 0;
+ }
+ }
+`;
+
+export const getWrapperStyles = (hasMedia = false) => css`
+ overflow: hidden;
+ padding-inline-start: ${spacing[200]}px;
+
+ ${hasMedia &&
+ css`
+ @media (min-width: ${breakpoints.Tablet}px) {
+ display: grid;
+ }
+
+ column-gap: ${spacing[400]}px;
+ grid-template:
+ 'desc img'
+ 'cta img'
+ 'cta img';
+ grid-template-columns: minmax(370px, 1fr) auto;
+ `}
+`;
+
+export const mediaStyles = css`
+ grid-area: img;
+ margin-block-start: ${spacing[200]}px;
+ max-width: 800px;
+ width: 100%;
+
+ @media (min-width: ${breakpoints.Tablet}px) {
+ margin-block-start: 0;
+ }
+
+ img,
+ svg {
+ max-width: 100%;
+ vertical-align: middle;
+ }
+`;
+
+export const titleStyles: Record> = {
+ [Theme.Dark]: {
+ [State.Current]: palette.white,
+ [State.Completed]: palette.green.base,
+ [State.Future]: palette.gray.light1,
+ },
+ [Theme.Light]: {
+ [State.Current]: palette.black,
+ [State.Completed]: palette.green.dark2,
+ [State.Future]: palette.gray.dark1,
+ },
+};
+
+export const getTitleStyles = (theme: Theme, state: State) => {
+ return css`
+ color: ${titleStyles[theme][state]};
+ padding-inline-start: ${spacing[200]}px;
+ line-height: ${typeScales.body1.lineHeight}px;
+ transition: ${transitionDuration.default}ms font-weight ease-in-out;
+
+ ${state !== State.Completed &&
+ css`
+ font-weight: ${fontWeights.bold};
+ `}
+ `;
+};
+
+export const getContentStyles = (isOpen = false, hasButtons = false) => css`
+ margin-block-end: ${spacing[400]}px;
+ transition: margin-block-end ${transitionDuration.slowest}ms ease-in-out;
+ width: 100%;
+
+ ${isOpen &&
+ hasButtons &&
+ css`
+ margin-block-end: ${spacing[200]}px;
+ `}
+`;
diff --git a/packages/vertical-stepper/src/VerticalStep/VerticalStep.tsx b/packages/vertical-stepper/src/VerticalStep/VerticalStep.tsx
new file mode 100644
index 0000000000..b91fa56987
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStep/VerticalStep.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+
+import { useDescendant } from '@leafygreen-ui/descendants';
+
+import {
+ useVerticalStepperContext,
+ VerticalStepperDescendantsContext,
+} from '../context';
+
+import { InternalVerticalStep } from './InternalVerticalStep';
+import { State, VerticalStepProps } from './VerticalStep.types';
+
+export const VerticalStep = React.forwardRef(
+ ({ ...rest }: VerticalStepProps, forwardRef) => {
+ const { index, ref } = useDescendant(
+ VerticalStepperDescendantsContext,
+ forwardRef,
+ );
+
+ const { currentStep, hasVerticalStepperParent } =
+ useVerticalStepperContext();
+
+ const getState = (index: number) => {
+ if (index === currentStep) return State.Current;
+ if (index < currentStep) return State.Completed;
+ return State.Future;
+ };
+
+ if (!hasVerticalStepperParent) {
+ throw Error(
+ '`VerticalStep` must be a child of a `VerticalStepper` instance',
+ );
+ }
+
+ return (
+
+ );
+ },
+);
+
+VerticalStep.displayName = 'VerticalStep';
diff --git a/packages/vertical-stepper/src/VerticalStep/VerticalStep.types.ts b/packages/vertical-stepper/src/VerticalStep/VerticalStep.types.ts
new file mode 100644
index 0000000000..9d630d0235
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStep/VerticalStep.types.ts
@@ -0,0 +1,43 @@
+import { ComponentPropsWithRef } from 'react';
+
+export interface VerticalStepProps extends ComponentPropsWithRef<'li'> {
+ /**
+ * The title of the step
+ */
+ title: string;
+
+ /**
+ * The description of the step. This will render below the title
+ */
+ description: React.ReactNode;
+
+ /**
+ * The image to the right of the text
+ */
+ media?: React.ReactNode;
+
+ /**
+ * Optional buttons that will render below the text
+ */
+ actions?: React.ReactNode;
+}
+
+export const State = {
+ Current: 'current',
+ Completed: 'completed',
+ Future: 'future',
+} as const;
+
+export type State = (typeof State)[keyof typeof State];
+
+export interface InternalVerticalStepProps extends VerticalStepProps {
+ /**
+ * The internal state of the step
+ */
+ state: State;
+
+ /**
+ * The index of the step
+ */
+ index: number;
+}
diff --git a/packages/vertical-stepper/src/VerticalStep/index.ts b/packages/vertical-stepper/src/VerticalStep/index.ts
new file mode 100644
index 0000000000..d1d8a88705
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStep/index.ts
@@ -0,0 +1,7 @@
+export { InternalVerticalStep } from './InternalVerticalStep';
+export { VerticalStep } from './VerticalStep';
+export {
+ InternalVerticalStepProps,
+ State,
+ type VerticalStepProps,
+} from './VerticalStep.types';
diff --git a/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.styles.tsx b/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.styles.tsx
new file mode 100644
index 0000000000..86c1c447e9
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.styles.tsx
@@ -0,0 +1,47 @@
+import { css, cx } from '@leafygreen-ui/emotion';
+import {
+ breakpoints,
+ spacing,
+ transitionDuration,
+} from '@leafygreen-ui/tokens';
+
+export const innerStyles = css`
+ overflow: hidden;
+`;
+
+export const getBaseStyles = (isCurrent = false) =>
+ cx(
+ css`
+ display: grid;
+ transition: grid-template-rows ${transitionDuration.slowest}ms ease-in-out;
+ grid-template-rows: 0fr;
+ margin-inline-start: -${spacing[200]}px;
+ `,
+ {
+ [css`
+ grid-template-rows: 1fr;
+ `]: isCurrent,
+ },
+ );
+
+export const getWrapperStyles = (isCurrent = false) =>
+ cx(
+ css`
+ padding-inline-start: ${spacing[200]}px;
+ padding-block-end: 0;
+ padding-block-start: ${spacing[200]}px;
+ transition: padding-block-end 400ms ease;
+
+ display: flex;
+ gap: ${spacing[200]}px;
+
+ @media (max-width: ${breakpoints.Tablet}px) {
+ flex-direction: column;
+ }
+ `,
+ {
+ [css`
+ padding-block-end: ${spacing[200]}px; // Add padding in here so that hover states are not cut off by the overflow
+ `]: isCurrent,
+ },
+ );
diff --git a/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.tsx b/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.tsx
new file mode 100644
index 0000000000..14c2d9aa5b
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+
+import { LGIDS_VERTICAL_STEPPER } from '../constants';
+import { State } from '../VerticalStep/VerticalStep.types';
+
+import {
+ getBaseStyles,
+ getWrapperStyles,
+ innerStyles,
+} from './VerticalStepActions.styles';
+import { VerticalStepActionsProps } from './VerticalStepActions.types';
+
+export const VerticalStepActions = ({
+ actions,
+ state,
+}: VerticalStepActionsProps) => {
+ const isCurrent = state === State.Current;
+
+ return (
+
+ );
+};
+
+VerticalStepActions.displayName = 'VerticalStepActions';
diff --git a/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.types.ts b/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.types.ts
new file mode 100644
index 0000000000..6d7918175b
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStepActions/VerticalStepActions.types.ts
@@ -0,0 +1,6 @@
+import { InternalVerticalStepProps } from '../VerticalStep/VerticalStep.types';
+
+export type VerticalStepActionsProps = Pick<
+ InternalVerticalStepProps,
+ 'actions' | 'state'
+>;
diff --git a/packages/vertical-stepper/src/VerticalStepActions/index.ts b/packages/vertical-stepper/src/VerticalStepActions/index.ts
new file mode 100644
index 0000000000..297d0252b9
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStepActions/index.ts
@@ -0,0 +1 @@
+export { VerticalStepActions } from './VerticalStepActions';
diff --git a/packages/vertical-stepper/src/VerticalStepper.stories.tsx b/packages/vertical-stepper/src/VerticalStepper.stories.tsx
new file mode 100644
index 0000000000..71a76bd1ed
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStepper.stories.tsx
@@ -0,0 +1,122 @@
+import React from 'react';
+import { StoryMetaType } from '@lg-tools/storybook-utils';
+import { StoryObj } from '@storybook/react';
+
+import Button, { Size, Variant } from '@leafygreen-ui/button';
+import { Link } from '@leafygreen-ui/typography';
+
+import { VerticalStep, VerticalStepper } from '.';
+
+export default {
+ title: 'Composition/Data Display/VerticalStepper',
+ component: VerticalStepper,
+ parameters: {
+ default: 'LiveExample',
+ },
+ args: {
+ darkMode: false,
+ currentStep: 0,
+ children: [
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam
+ efficitur nunc mattis magna pretium, id mattis metus vestibulum.
+ Integer cursus ex ante, ut molestie lorem vestibulum id.{' '}
+ Im a link
+ >
+ }
+ actions={
+
+ }
+ />,
+
+
+
+ >
+ }
+ media={
}
+ />,
+
+
+
+ >
+ }
+ media={
}
+ />,
+ primary button}
+ />,
+ primary button}
+ media={
}
+ />,
+ ,
+ ],
+ },
+ argTypes: {
+ darkMode: {
+ control: 'boolean',
+ },
+ currentStep: {
+ control: {
+ type: 'number',
+ min: 0,
+ max: 6,
+ },
+ },
+ },
+} satisfies StoryMetaType;
+
+export const LiveExample = {
+ render: ({ ...args }) => ,
+ parameters: {
+ chromatic: {
+ disableSnapshot: true,
+ },
+ },
+} satisfies StoryObj;
+
+export const Generated = {
+ render: () => <>>,
+ parameters: {
+ generate: {
+ combineArgs: {
+ darkMode: [false, true],
+ currentStep: [0, 2, 6],
+ },
+ },
+ },
+} satisfies StoryObj;
diff --git a/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.spec.tsx b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.spec.tsx
new file mode 100644
index 0000000000..76f183e63f
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.spec.tsx
@@ -0,0 +1,183 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import { axe } from 'jest-axe';
+
+import Button from '@leafygreen-ui/button';
+import { Link } from '@leafygreen-ui/typography';
+
+import { LGIDS_VERTICAL_STEPPER } from '../constants';
+import { VerticalStep } from '../VerticalStep/VerticalStep';
+
+import { VerticalStepper, VerticalStepperProps } from '.';
+
+const currentStepString = 'li[data-state="current"]';
+const completedStepString = 'li[data-state="completed"]';
+const futureStepString = 'li[data-state="future"]';
+
+const childrenData = [
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam
+ efficitur nunc mattis magna pretium, id mattis metus vestibulum. Integer
+ cursus ex ante, ut molestie lorem vestibulum id.{' '}
+ Im a link
+ >
+ }
+ actions={}
+ />,
+
+
+
+ >
+ }
+ media={
}
+ />,
+
+
+
+ >
+ }
+ media={
}
+ />,
+ primary button}
+ />,
+ ,
+ primary button}
+ media={
}
+ />,
+];
+
+const renderVerticalStepper = ({
+ currentStep: currentStepProp = 2,
+ children = childrenData,
+ ...props
+}: Partial) => {
+ const utils = render(
+
+ {children}
+ ,
+ );
+
+ const verticalStepper = utils.getByTestId(LGIDS_VERTICAL_STEPPER.root);
+ const verticalSteps = utils.getAllByTestId(LGIDS_VERTICAL_STEPPER.step);
+ const currentStep = verticalStepper.querySelectorAll(currentStepString);
+ const completedSteps = verticalStepper.querySelectorAll(completedStepString);
+ const futureSteps = verticalStepper.querySelectorAll(futureStepString);
+
+ return {
+ ...utils,
+ verticalStepper,
+ verticalSteps,
+ currentStep,
+ completedSteps,
+ futureSteps,
+ };
+};
+
+describe('packages/vertical-stepper', () => {
+ describe('a11y', () => {
+ test('does not have basic accessibility issues', async () => {
+ const { container } = renderVerticalStepper({});
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+ });
+
+ test('warns if there is only 1 step', () => {
+ const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+
+ render(
+
+ primary button}
+ media={
}
+ />
+ ,
+ );
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy).toHaveBeenCalledWith(
+ 'Two or more components are required',
+ );
+ spy.mockClear();
+ });
+
+ describe('steps', () => {
+ test('are all rendered', async () => {
+ const { verticalSteps } = renderVerticalStepper({});
+ expect(verticalSteps).toHaveLength(6);
+ });
+
+ describe('current', () => {
+ test('has 1 current step', async () => {
+ const { currentStep } = renderVerticalStepper({});
+ expect(currentStep).toHaveLength(1);
+ });
+ });
+
+ describe('completed', () => {
+ test('has correct number of steps', async () => {
+ const { completedSteps } = renderVerticalStepper({});
+ expect(completedSteps).toHaveLength(2);
+ });
+ });
+
+ describe('future', () => {
+ test('has correct number of steps', async () => {
+ const { futureSteps } = renderVerticalStepper({});
+ expect(futureSteps).toHaveLength(3);
+ });
+ });
+ });
+
+ describe('TypeScript types are correct', () => {
+ // eslint-disable-next-line jest/no-disabled-tests
+ test.skip('VerticalStepper component types', () => {
+ <>
+ {/* @ts-expect-error Missing children and currentStep */}
+
+
+ {/* @ts-expect-error Missing children */}
+
+
+ {/* @ts-expect-error Missing currentStep */}
+
+ {/* @ts-expect-error Missing all props */}
+
+
+
+
+ {/* @ts-expect-error Missing all props */}
+
+
+ >;
+ });
+ });
+});
diff --git a/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.styles.ts b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.styles.ts
new file mode 100644
index 0000000000..c89b8fdbdb
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.styles.ts
@@ -0,0 +1,7 @@
+import { css } from '@leafygreen-ui/emotion';
+
+export const baseStyles = css`
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+`;
diff --git a/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.tsx b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.tsx
new file mode 100644
index 0000000000..bcddd8718c
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.tsx
@@ -0,0 +1,74 @@
+import React, { useMemo } from 'react';
+
+import {
+ DescendantsProvider,
+ useInitDescendants,
+} from '@leafygreen-ui/descendants';
+import { cx } from '@leafygreen-ui/emotion';
+import LeafyGreenProvider, {
+ useDarkMode,
+} from '@leafygreen-ui/leafygreen-provider';
+
+import { LGIDS_VERTICAL_STEPPER } from '../constants';
+import {
+ VerticalStepperDescendantsContext,
+ VerticalStepperProvider,
+} from '../context';
+
+import { baseStyles } from './VerticalStepper.styles';
+import { VerticalStepperProps } from './VerticalStepper.types';
+
+export const VerticalStepper = React.forwardRef<
+ HTMLOListElement,
+ VerticalStepperProps
+>(
+ (
+ {
+ currentStep = 0,
+ darkMode: darkModeProp,
+ children,
+ className,
+ 'data-lgid': dataLgId = LGIDS_VERTICAL_STEPPER.root,
+ }: VerticalStepperProps,
+ forwardRef,
+ ) => {
+ const { darkMode } = useDarkMode(darkModeProp);
+ const childrenLength = React.Children.toArray(children).length;
+
+ const { descendants, dispatch } = useInitDescendants(
+ VerticalStepperDescendantsContext,
+ );
+
+ const providerData = useMemo(() => {
+ return { currentStep, hasVerticalStepperParent: true };
+ }, [currentStep]);
+
+ if (childrenLength < 2) {
+ console.warn('Two or more components are required');
+ return null;
+ }
+
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+ },
+);
+
+VerticalStepper.displayName = 'VerticalStepper';
diff --git a/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.types.ts b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.types.ts
new file mode 100644
index 0000000000..136e0782a1
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStepper/VerticalStepper.types.ts
@@ -0,0 +1,18 @@
+import React, { ComponentPropsWithRef } from 'react';
+
+import { DarkModeProps, LgIdProps } from '@leafygreen-ui/lib';
+
+export interface VerticalStepperProps
+ extends DarkModeProps,
+ LgIdProps,
+ Omit, 'children'> {
+ /**
+ * Zero-based. The index of the current step that will appear active. All steps will be marked as completed if the currentStep equals the number of steps.
+ */
+ currentStep: number;
+
+ /**
+ * Two or more `` components
+ */
+ children: React.ReactNode;
+}
diff --git a/packages/vertical-stepper/src/VerticalStepper/index.ts b/packages/vertical-stepper/src/VerticalStepper/index.ts
new file mode 100644
index 0000000000..1219e654b5
--- /dev/null
+++ b/packages/vertical-stepper/src/VerticalStepper/index.ts
@@ -0,0 +1,2 @@
+export { VerticalStepper } from './VerticalStepper';
+export { VerticalStepperProps } from './VerticalStepper.types';
diff --git a/packages/vertical-stepper/src/constants.ts b/packages/vertical-stepper/src/constants.ts
new file mode 100644
index 0000000000..9dbfbe57df
--- /dev/null
+++ b/packages/vertical-stepper/src/constants.ts
@@ -0,0 +1,10 @@
+const LGID_ROOT = 'lg-vertical_stepper';
+
+export const LGIDS_VERTICAL_STEPPER = {
+ root: LGID_ROOT,
+ step: `${LGID_ROOT}-step`,
+ stepTitle: `${LGID_ROOT}-step-title`,
+ stepDescription: `${LGID_ROOT}-step-description`,
+ stepMedia: `${LGID_ROOT}-step-media`,
+ stepActions: `${LGID_ROOT}-step-actions`,
+} as const;
diff --git a/packages/vertical-stepper/src/context/VerticalStepperContext.tsx b/packages/vertical-stepper/src/context/VerticalStepperContext.tsx
new file mode 100644
index 0000000000..e020e63575
--- /dev/null
+++ b/packages/vertical-stepper/src/context/VerticalStepperContext.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { createContext, PropsWithChildren, useContext } from 'react';
+
+export interface VerticalStepperContextProps {
+ currentStep: number;
+ hasVerticalStepperParent: boolean;
+}
+
+export const VerticalStepperContext =
+ createContext({
+ currentStep: 0,
+ hasVerticalStepperParent: false,
+ });
+
+export const useVerticalStepperContext = () => {
+ return useContext(VerticalStepperContext);
+};
+
+export const VerticalStepperProvider = ({
+ children,
+ currentStep,
+ hasVerticalStepperParent,
+}: PropsWithChildren) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/vertical-stepper/src/context/VerticalStepperDescendantsContext.ts b/packages/vertical-stepper/src/context/VerticalStepperDescendantsContext.ts
new file mode 100644
index 0000000000..154d637a3b
--- /dev/null
+++ b/packages/vertical-stepper/src/context/VerticalStepperDescendantsContext.ts
@@ -0,0 +1,11 @@
+import {
+ createDescendantsContext,
+ useDescendantsContext,
+} from '@leafygreen-ui/descendants';
+
+export const VerticalStepperDescendantsContext =
+ createDescendantsContext('VerticalStepperDescendantsContext');
+
+export function useVerticalStepperDescendantsContext() {
+ return useDescendantsContext(VerticalStepperDescendantsContext);
+}
diff --git a/packages/vertical-stepper/src/context/index.ts b/packages/vertical-stepper/src/context/index.ts
new file mode 100644
index 0000000000..746c7db028
--- /dev/null
+++ b/packages/vertical-stepper/src/context/index.ts
@@ -0,0 +1,5 @@
+export {
+ useVerticalStepperContext,
+ VerticalStepperProvider,
+} from './VerticalStepperContext';
+export { VerticalStepperDescendantsContext } from './VerticalStepperDescendantsContext';
diff --git a/packages/vertical-stepper/src/index.ts b/packages/vertical-stepper/src/index.ts
new file mode 100644
index 0000000000..91b0c6f61f
--- /dev/null
+++ b/packages/vertical-stepper/src/index.ts
@@ -0,0 +1,2 @@
+export { VerticalStep, type VerticalStepProps } from './VerticalStep';
+export { VerticalStepper, type VerticalStepperProps } from './VerticalStepper';
diff --git a/packages/vertical-stepper/tsconfig.json b/packages/vertical-stepper/tsconfig.json
new file mode 100644
index 0000000000..f8088449c0
--- /dev/null
+++ b/packages/vertical-stepper/tsconfig.json
@@ -0,0 +1,44 @@
+{
+ "extends": "@lg-tools/build/config/package.tsconfig.json",
+ "compilerOptions": {
+ "paths": {
+ "@leafygreen-ui/icon/dist/*": [
+ "../icon/src/generated/*"
+ ],
+ "@leafygreen-ui/*": [
+ "../*/src"
+ ]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["**/*.stories.*"],
+ "references": [
+ {
+ "path": "../button"
+ },
+ {
+ "path": "../descendants"
+ },
+ {
+ "path": "../emotion"
+ },
+ {
+ "path": "../icon"
+ },
+ {
+ "path": "../leafygreen-provider"
+ },
+ {
+ "path": "../lib"
+ },
+ {
+ "path": "../palette"
+ },
+ {
+ "path": "../tokens"
+ },
+ {
+ "path": "../typography"
+ }
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b353d9ba05..a10e6d7287 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3753,6 +3753,43 @@ importers:
specifier: workspace:^
version: link:../../tools/build
+ packages/vertical-stepper:
+ dependencies:
+ '@leafygreen-ui/button':
+ specifier: workspace:^
+ version: link:../button
+ '@leafygreen-ui/descendants':
+ specifier: workspace:^
+ version: link:../descendants
+ '@leafygreen-ui/emotion':
+ specifier: workspace:^
+ version: link:../emotion
+ '@leafygreen-ui/icon':
+ specifier: workspace:^
+ version: link:../icon
+ '@leafygreen-ui/leafygreen-provider':
+ specifier: workspace:^
+ version: link:../leafygreen-provider
+ '@leafygreen-ui/lib':
+ specifier: workspace:^
+ version: link:../lib
+ '@leafygreen-ui/palette':
+ specifier: workspace:^
+ version: link:../palette
+ '@leafygreen-ui/tokens':
+ specifier: workspace:^
+ version: link:../tokens
+ '@leafygreen-ui/typography':
+ specifier: workspace:^
+ version: link:../typography
+ devDependencies:
+ '@lg-tools/build':
+ specifier: workspace:^
+ version: link:../../tools/build
+ '@lg-tools/storybook-utils':
+ specifier: workspace:^
+ version: link:../../tools/storybook-utils
+
tools/build:
dependencies:
'@babel/core':
diff --git a/tools/install/src/ALL_PACKAGES.ts b/tools/install/src/ALL_PACKAGES.ts
index 7ee8b6b4de..6ee2eddfcc 100644
--- a/tools/install/src/ALL_PACKAGES.ts
+++ b/tools/install/src/ALL_PACKAGES.ts
@@ -76,6 +76,7 @@ export const ALL_PACKAGES = [
'@leafygreen-ui/toolbar',
'@leafygreen-ui/tooltip',
'@leafygreen-ui/typography',
+ '@leafygreen-ui/vertical-stepper',
'@lg-charts/chart-card',
'@lg-charts/colors',
'@lg-charts/core',