diff --git a/opentrons-ai-client/src/molecules/Accordion/Accordion.stories.tsx b/opentrons-ai-client/src/molecules/Accordion/Accordion.stories.tsx
new file mode 100644
index 00000000000..388267061b0
--- /dev/null
+++ b/opentrons-ai-client/src/molecules/Accordion/Accordion.stories.tsx
@@ -0,0 +1,74 @@
+import { I18nextProvider } from 'react-i18next'
+import { COLORS, Flex, SPACING } from '@opentrons/components'
+import { i18n } from '../../i18n'
+import { Accordion } from './index'
+
+import type { Meta, StoryObj } from '@storybook/react'
+
+const contentExample: React.ReactNode = (
+
+
What's your scientific application?
+
Describe what you are trying to do
+
+ Example: “The protocol performs automated liquid handling for Pierce BCA
+ Protein Assay Kit to determine protein concentrations in various sample
+ types, such as cell lysates and eluates of purification process."
+
+
+)
+
+const meta: Meta = {
+ title: 'AI/molecules/Accordion',
+ component: Accordion,
+ decorators: [
+ Story => (
+
+
+
+
+
+ ),
+ ],
+}
+export default meta
+type Story = StoryObj
+
+export const AccordionCollapsed: Story = {
+ args: {
+ id: 'accordion',
+ handleClick: () => {
+ alert('Accordion clicked')
+ },
+ heading: 'Application',
+ children: contentExample,
+ },
+}
+
+export const AccordionCompleted: Story = {
+ args: {
+ id: 'accordion',
+ isCompleted: true,
+ heading: 'Application',
+ },
+}
+
+export const AccordionExpanded: Story = {
+ args: {
+ id: 'accordion2',
+ isOpen: true,
+ heading: 'Application',
+ children: contentExample,
+ },
+}
+
+export const AccordionDisabled: Story = {
+ args: {
+ id: 'accordion3',
+ handleClick: () => {
+ alert('Accordion clicked')
+ },
+ disabled: true,
+ heading: 'Application',
+ children: contentExample,
+ },
+}
diff --git a/opentrons-ai-client/src/molecules/Accordion/__tests__/Accordion.test.tsx b/opentrons-ai-client/src/molecules/Accordion/__tests__/Accordion.test.tsx
new file mode 100644
index 00000000000..4be089d8398
--- /dev/null
+++ b/opentrons-ai-client/src/molecules/Accordion/__tests__/Accordion.test.tsx
@@ -0,0 +1,68 @@
+import type * as React from 'react'
+import { describe, it, vi, beforeEach, expect } from 'vitest'
+import { fireEvent, screen } from '@testing-library/react'
+import { renderWithProviders } from '../../../__testing-utils__'
+
+import { Accordion } from '../index'
+
+const mockHandleClick = vi.fn()
+const render = (props: React.ComponentProps) => {
+ return renderWithProviders()
+}
+
+describe('Accordion', () => {
+ let props: React.ComponentProps
+
+ beforeEach(() => {
+ props = {
+ id: 'accordion-test',
+ handleClick: mockHandleClick,
+ isOpen: false,
+ isCompleted: false,
+ heading: 'Accordion heading',
+ children: Accordion content
,
+ }
+ })
+
+ it('should render an accordion with heading', () => {
+ render(props)
+ const accordion = screen.getByRole('button', { name: 'Accordion heading' })
+ expect(accordion).toBeInTheDocument()
+ })
+
+ it('should display content if isOpen is true', () => {
+ props.isOpen = true
+ render(props)
+ const accordionContent = screen.getByText('Accordion content')
+ expect(accordionContent).toBeVisible()
+ })
+
+ it('should not display content if isOpen is false', () => {
+ render(props)
+ const accordionContent = screen.queryByText('Accordion content')
+ expect(accordionContent).not.toBeVisible()
+ })
+
+ it("should call handleClick when the accordion's header is clicked", () => {
+ render(props)
+ const accordionHeader = screen.getByRole('button', {
+ name: 'Accordion heading',
+ })
+ fireEvent.click(accordionHeader)
+ expect(mockHandleClick).toHaveBeenCalled()
+ })
+
+ it('should display a check icon if isCompleted is true', () => {
+ props.isCompleted = true
+ render(props)
+ const checkIcon = screen.getByTestId('accordion-test-ot-check')
+ expect(checkIcon).toBeInTheDocument()
+ })
+
+ it('should not display a check icon if isCompleted is false', () => {
+ props.isCompleted = false
+ render(props)
+ const checkIcon = screen.queryByTestId('accordion-test-ot-check')
+ expect(checkIcon).not.toBeInTheDocument()
+ })
+})
diff --git a/opentrons-ai-client/src/molecules/Accordion/index.tsx b/opentrons-ai-client/src/molecules/Accordion/index.tsx
new file mode 100644
index 00000000000..885f6af1745
--- /dev/null
+++ b/opentrons-ai-client/src/molecules/Accordion/index.tsx
@@ -0,0 +1,158 @@
+import { useRef, useState, useEffect } from 'react'
+import styled from 'styled-components'
+import {
+ Flex,
+ Icon,
+ StyledText,
+ COLORS,
+ BORDERS,
+ DIRECTION_COLUMN,
+ SIZE_AUTO,
+ SPACING,
+ JUSTIFY_SPACE_BETWEEN,
+ ALIGN_CENTER,
+ CURSOR_POINTER,
+ TEXT_ALIGN_LEFT,
+ DISPLAY_FLEX,
+ OVERFLOW_HIDDEN,
+ CURSOR_DEFAULT,
+} from '@opentrons/components'
+
+interface AccordionProps {
+ id?: string
+ handleClick: () => void
+ heading: string
+ isOpen?: boolean
+ isCompleted?: boolean
+ disabled?: boolean
+ children: React.ReactNode
+}
+
+const ACCORDION = 'accordion'
+const BUTTON = 'button'
+const CONTENT = 'content'
+const OT_CHECK = 'ot-check'
+
+const AccordionContainer = styled(Flex)<{
+ isOpen: boolean
+ disabled: boolean
+}>`
+ flex-direction: ${DIRECTION_COLUMN};
+ width: 100%;
+ height: ${SIZE_AUTO};
+ padding: ${SPACING.spacing24} ${SPACING.spacing32};
+ border-radius: ${BORDERS.borderRadius16};
+ background-color: ${COLORS.white};
+ cursor: ${props =>
+ props.isOpen || props.disabled ? `${CURSOR_DEFAULT}` : `${CURSOR_POINTER}`};
+`
+
+const AccordionButton = styled.button<{ isOpen: boolean; disabled: boolean }>`
+ display: ${DISPLAY_FLEX};
+ justify-content: ${JUSTIFY_SPACE_BETWEEN};
+ align-items: ${ALIGN_CENTER};
+ width: 100%;
+ background: none;
+ border: none;
+ cursor: ${props =>
+ props.isOpen || props.disabled ? `${CURSOR_DEFAULT}` : `${CURSOR_POINTER}`};
+ text-align: ${TEXT_ALIGN_LEFT};
+
+ &:focus-visible {
+ outline: 2px solid ${COLORS.blue50};
+ }
+`
+
+const HeadingText = styled(StyledText)`
+ flex: 1;
+ margin-right: ${SPACING.spacing8};
+`
+
+const AccordionContent = styled.div<{
+ id: string
+ isOpen: boolean
+ contentHeight: number
+}>`
+ transition: height 0.3s ease, margin-top 0.3s ease, visibility 0.3s ease;
+ overflow: ${OVERFLOW_HIDDEN};
+ height: ${props => (props.isOpen ? `${props.contentHeight}px` : '0')};
+ margin-top: ${props => (props.isOpen ? `${SPACING.spacing16}` : '0')};
+ pointer-events: ${props => (props.isOpen ? 'auto' : 'none')};
+ visibility: ${props => (props.isOpen ? 'unset' : 'hidden')};
+`
+
+export function Accordion({
+ id = ACCORDION,
+ handleClick,
+ isOpen = false,
+ isCompleted = false,
+ disabled = false,
+ heading = '',
+ children,
+}: AccordionProps): JSX.Element {
+ const contentRef = useRef(null)
+ const [contentHeight, setContentHeight] = useState(0)
+
+ useEffect(() => {
+ if (contentRef.current != null) {
+ setContentHeight(contentRef.current.scrollHeight)
+ }
+ }, [isOpen])
+
+ const handleContainerClick = (e: React.MouseEvent): void => {
+ // Prevent the click event from propagating to the button
+ if (
+ (e.target as HTMLElement).tagName !== 'BUTTON' &&
+ !disabled &&
+ !isOpen
+ ) {
+ handleClick()
+ }
+ }
+
+ const handleButtonClick = (e: React.MouseEvent): void => {
+ // Stop the event from propagating to the container
+ if (!isOpen && !disabled) {
+ e.stopPropagation()
+ handleClick()
+ }
+ }
+
+ return (
+
+
+ {heading}
+ {isCompleted && (
+
+ )}
+
+
+ {children}
+
+
+ )
+}