+ );
+
+ if (open) {
+ return (
+
+ {panelDiv}
+
+ );
+ }
+
+ return panelDiv;
+ },
+);
diff --git a/packages/lab/src/side-panel/SidePanelCloseTrigger.tsx b/packages/lab/src/side-panel/SidePanelCloseTrigger.tsx
new file mode 100644
index 00000000000..12c9dfb0009
--- /dev/null
+++ b/packages/lab/src/side-panel/SidePanelCloseTrigger.tsx
@@ -0,0 +1,57 @@
+import {
+ type ButtonProps,
+ getRefFromChildren,
+ mergeProps,
+ useForkRef,
+} from "@salt-ds/core";
+import {
+ cloneElement,
+ forwardRef,
+ isValidElement,
+ type ReactNode,
+} from "react";
+import { useSidePanelGroup } from "./SidePanelGroupContext";
+
+export interface SidePanelCloseButtonProps extends ButtonProps {
+ children: ReactNode;
+}
+
+export const SidePanelCloseTrigger = forwardRef<
+ HTMLElement,
+ SidePanelCloseButtonProps
+>(function SidePanelCloseTrigger({ children, onClick, ...rest }, ref) {
+ const { setOpen } = useSidePanelGroup();
+
+ const handleClick: ButtonProps["onClick"] = (event) => {
+ onClick?.(event);
+
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ setOpen?.(false);
+ };
+
+ const handleRef = useForkRef(getRefFromChildren(children), ref);
+
+ if (!children || !isValidElement(children)) {
+ return <>{children}>;
+ }
+
+ const mergedProps = mergeProps(
+ {
+ onClick: handleClick,
+ ...rest,
+ },
+ children.props,
+ );
+
+ return (
+ <>
+ {cloneElement(children, {
+ ...mergedProps,
+ ref: handleRef,
+ })}
+ >
+ );
+});
diff --git a/packages/lab/src/side-panel/SidePanelGroup.tsx b/packages/lab/src/side-panel/SidePanelGroup.tsx
new file mode 100644
index 00000000000..b1c9d24e97b
--- /dev/null
+++ b/packages/lab/src/side-panel/SidePanelGroup.tsx
@@ -0,0 +1,96 @@
+import { useControlled, useId } from "@salt-ds/core";
+import {
+ type MutableRefObject,
+ type ReactNode,
+ useCallback,
+ useMemo,
+ useState,
+} from "react";
+import {
+ SidePanelGroupContext,
+ type SidePanelGroupContextValue,
+} from "./SidePanelGroupContext";
+
+export interface SidePanelGroupProps {
+ /**
+ * Whether the panel is open
+ */
+ open?: boolean;
+ /**
+ * Default open state when initially rendered
+ */
+ defaultOpen?: boolean;
+ /**
+ * Callback when open state should change
+ */
+ onOpenChange?: (open: boolean) => void;
+ /**
+ * SidePanelGroup children, should include SidePanel and SidePanelTrigger
+ */
+ children: ReactNode;
+}
+
+export function SidePanelGroup(props: SidePanelGroupProps) {
+ const [activeTriggerId, setActiveTriggerId] = useState(
+ undefined,
+ );
+ const [triggerRef, setTriggerRef] = useState<
+ MutableRefObject | undefined
+ >(undefined);
+
+ const { children, open: openProp, defaultOpen, onOpenChange } = props;
+
+ const [open, setOpenState] = useControlled({
+ default: Boolean(defaultOpen),
+ controlled: openProp,
+ name: "SidePanelGroup",
+ state: "open",
+ });
+
+ const panelId = useId();
+
+ const setOpen = useCallback(
+ (newOpen: boolean) => {
+ if (newOpen === open) {
+ return;
+ }
+
+ setOpenState(newOpen);
+ if (!newOpen) {
+ setActiveTriggerId(undefined);
+ }
+ onOpenChange?.(newOpen);
+ },
+ [open, onOpenChange],
+ );
+
+ const activateTrigger = useCallback(
+ (
+ triggerId: string,
+ triggerElement: MutableRefObject,
+ ) => {
+ setActiveTriggerId(triggerId);
+ setTriggerRef(triggerElement);
+ setOpen(true);
+ },
+ [setOpen],
+ );
+
+ const contextValue = useMemo(
+ () => ({
+ open,
+ setOpen,
+ panelId,
+ activeTriggerId,
+ triggerRef,
+ activateTrigger,
+ }),
+ [open, setOpen, panelId, activeTriggerId, triggerRef, activateTrigger],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/lab/src/side-panel/SidePanelGroupContext.tsx b/packages/lab/src/side-panel/SidePanelGroupContext.tsx
new file mode 100644
index 00000000000..86b6be68434
--- /dev/null
+++ b/packages/lab/src/side-panel/SidePanelGroupContext.tsx
@@ -0,0 +1,45 @@
+import { createContext } from "@salt-ds/core";
+import { type MutableRefObject, useContext } from "react";
+
+export interface SidePanelGroupContextValue {
+ /**
+ * Whether the side panel is currently open.
+ */
+ open?: boolean;
+ /**
+ * Function to set the open state of the panel.
+ */
+ setOpen?: (open: boolean) => void;
+ /**
+ * ID of the panel.
+ */
+ panelId?: string;
+ /**
+ * ID of the active trigger, used to identify which trigger should receive focus when the panel closes.
+ */
+ activeTriggerId?: string;
+ /**
+ * DOM reference of the active trigger, used to restore focus when the panel closes.
+ */
+ triggerRef?: MutableRefObject;
+ /**
+ * Activates a trigger: sets its ID and ref, keeps the panel open, and prepares for focus restoration on close.
+ */
+ activateTrigger: (
+ triggerId: string,
+ triggerElement: MutableRefObject,
+ ) => void;
+}
+
+export const SidePanelGroupContext = createContext(
+ "SidePanelGroupContext",
+ {
+ open: undefined,
+ setOpen: undefined,
+ activateTrigger: () => undefined,
+ },
+);
+
+export function useSidePanelGroup() {
+ return useContext(SidePanelGroupContext);
+}
diff --git a/packages/lab/src/side-panel/SidePanelTrigger.tsx b/packages/lab/src/side-panel/SidePanelTrigger.tsx
new file mode 100644
index 00000000000..5fe0c200d49
--- /dev/null
+++ b/packages/lab/src/side-panel/SidePanelTrigger.tsx
@@ -0,0 +1,68 @@
+import { mergeProps, useForkRef, useId } from "@salt-ds/core";
+import {
+ type ComponentPropsWithoutRef,
+ cloneElement,
+ forwardRef,
+ isValidElement,
+ type MouseEvent,
+ type ReactNode,
+ useRef,
+} from "react";
+import { useSidePanelGroup } from "./SidePanelGroupContext";
+
+export interface SidePanelTriggerProps
+ extends Omit<
+ ComponentPropsWithoutRef<"button">,
+ "aria-controls" | "aria-expanded"
+ > {
+ children: ReactNode;
+}
+
+export const SidePanelTrigger = forwardRef<
+ HTMLButtonElement,
+ SidePanelTriggerProps
+>(function SidePanelTrigger(props, ref) {
+ const { children, onClick, ...rest } = props;
+ const { open, activeTriggerId, setOpen, activateTrigger, panelId } =
+ useSidePanelGroup();
+ const triggerRef = useRef(null);
+ const triggerId = useId();
+ const handleRef = useForkRef(triggerRef, ref);
+
+ const handleClick = (event: MouseEvent) => {
+ onClick?.(event);
+
+ if (!triggerRef.current || !triggerId) {
+ return;
+ }
+
+ const isActiveTriggerOpen = open && activeTriggerId === triggerId;
+
+ if (isActiveTriggerOpen) {
+ setOpen?.(false);
+ return;
+ }
+
+ activateTrigger(triggerId, triggerRef);
+ };
+
+ if (!children || !isValidElement<{ ref?: unknown }>(children)) {
+ return <>{children}>;
+ }
+
+ const mergedProps = mergeProps(
+ {
+ onClick: handleClick,
+ ...rest,
+ },
+ children.props,
+ );
+
+ mergedProps["aria-expanded"] = open && activeTriggerId === triggerId;
+ mergedProps["aria-controls"] = panelId;
+
+ return cloneElement(children, {
+ ...mergedProps,
+ ref: handleRef,
+ });
+});
diff --git a/packages/lab/src/side-panel/index.ts b/packages/lab/src/side-panel/index.ts
new file mode 100644
index 00000000000..f54df9083e2
--- /dev/null
+++ b/packages/lab/src/side-panel/index.ts
@@ -0,0 +1,4 @@
+export * from "./SidePanel";
+export * from "./SidePanelCloseTrigger";
+export * from "./SidePanelGroup";
+export * from "./SidePanelTrigger";
diff --git a/packages/lab/stories/side-panel/images/article1.png b/packages/lab/stories/side-panel/images/article1.png
new file mode 100644
index 00000000000..16cb9816068
Binary files /dev/null and b/packages/lab/stories/side-panel/images/article1.png differ
diff --git a/packages/lab/stories/side-panel/images/article2.png b/packages/lab/stories/side-panel/images/article2.png
new file mode 100644
index 00000000000..3b98f2e6893
Binary files /dev/null and b/packages/lab/stories/side-panel/images/article2.png differ
diff --git a/packages/lab/stories/side-panel/images/card.png b/packages/lab/stories/side-panel/images/card.png
new file mode 100644
index 00000000000..0902d1be330
Binary files /dev/null and b/packages/lab/stories/side-panel/images/card.png differ
diff --git a/packages/lab/stories/side-panel/images/mobile.png b/packages/lab/stories/side-panel/images/mobile.png
new file mode 100644
index 00000000000..ebe32b48436
Binary files /dev/null and b/packages/lab/stories/side-panel/images/mobile.png differ
diff --git a/packages/lab/stories/side-panel/side-panel.qa.stories.tsx b/packages/lab/stories/side-panel/side-panel.qa.stories.tsx
new file mode 100644
index 00000000000..d2d9482480f
--- /dev/null
+++ b/packages/lab/stories/side-panel/side-panel.qa.stories.tsx
@@ -0,0 +1,79 @@
+import { H2, StackLayout, Text } from "@salt-ds/core";
+import { SidePanel, SidePanelGroup, type SidePanelProps } from "@salt-ds/lab";
+import type { Meta, StoryFn } from "@storybook/react-vite";
+import { QAContainer, type QAContainerProps } from "docs/components";
+import type { ReactNode } from "react";
+
+export default {
+ title: "Lab/Side Panel/Side Panel QA",
+ component: SidePanel,
+} as Meta;
+
+function FakeSidePanel({
+ children,
+ variant = "primary",
+ position = "right",
+}: {
+ children: ReactNode;
+ variant?: SidePanelProps["variant"];
+ position?: SidePanelProps["position"];
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+const SidePanelTemplate: StoryFn = ({
+ variant = "primary",
+ position = "right",
+}) => {
+ return (
+
+
+
+
Title
+
+ Lorem Ipsum is simply dummy text of the printing and typesetting
+ industry. Lorem Ipsum has been the industry's standard dummy text
+ ever since the 1500s, when an unknown printer took a galley of type
+ and scrambled it to make a type specimen book.
+
+
+
+
+
+ This placeholder text is provided to illustrate how content will
+ appear within the component. The sentences are intended for
+ demonstration only and do not convey specific information. Generic
+ examples like this help review layout, spacing, and overall
+ design. Adjust the wording as needed to fit your use case or
+ display requirements.
+
+ {Array.from({ length: 7 }, (_, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: Acceptable in this case since content is static and not re-orderable
+
+ ))}
+
+
+
+
+ );
+};
+
+export const Left: StoryFn = (args) => {
+ const headingId = useId();
+
+ return (
+
+
+
+
+
+
+
+
Section Title
+
+ This placeholder text is provided to illustrate how content will
+ appear within the component. The sentences are intended for
+ demonstration only and do not convey specific information. Generic
+ examples like this help review layout, spacing, and overall
+ design. Adjust the wording as needed to fit your use case or
+ display requirements.
+
+ {Array.from({ length: 7 }, (_, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: Acceptable in this case since content is static and not re-orderable
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const Controlled: StoryFn = (args) => {
+ const [open, setOpen] = useState(false);
+ const headingId = useId();
+
+ return (
+
+
+
+
+
+
+
+
+
+ setOpen(false)}>
+
+
+
Section Title
+
+ This placeholder text is provided to illustrate how content will
+ appear within the component. The sentences are intended for
+ demonstration only and do not convey specific information. Generic
+ examples like this help review layout, spacing, and overall
+ design. Adjust the wording as needed to fit your use case or
+ display requirements.
+
+ {Array.from({ length: 7 }, (_, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: Acceptable in this case since content is static and not re-orderable
+
+ ))}
+
+
+
+
+ );
+};
+
+export const ManualTrigger: StoryFn = (args) => {
+ const [open, setOpen] = useState(false);
+ const id = useId();
+ const headingId = useId();
+
+ return (
+
+
+
+
+
+
+
+ setOpen(false)}
+ style={{ marginLeft: "auto" }}
+ >
+
+
+
+
+
+
+ );
+};
diff --git a/site/docs/components/side-panel/accessibility.mdx b/site/docs/components/side-panel/accessibility.mdx
new file mode 100644
index 00000000000..9b5b00376c3
--- /dev/null
+++ b/site/docs/components/side-panel/accessibility.mdx
@@ -0,0 +1,44 @@
+---
+title:
+ $ref: ./#/title
+layout: DetailComponent
+sidebar:
+ exclude: true
+data:
+ $ref: ./#/data
+---
+
+## Best practices
+
+**Provide an accessible name for the `SidePanel`**
+
+Use `aria-labelledby` to link to a heading within the panel, or `aria-label` to provide a descriptive name for the `region`.
+
+## Keyboard interactions
+
+
+
+
+- Tab moves focus through focusable content elements in the panel in reading order.
+- Because the panel is non‑modal, after the last focusable element in the panel, focus continues to the next focusable element that follows the trigger in page order. Focus can leave the panel.
+
+
+
+
+- This action moves focus to the previous focusable content element in the panel.
+- From the first focusable element in the panel, focus returns to the trigger (and can continue backward to earlier page elements). Focus can leave the panel.
+
+
+
+
+- If focus is on the trigger element, this action activates the element and opens the side panel. Focus moves into the panel (to the container or first focusable element). Panel items appear immediately after the trigger in the tab sequence.
+- If focus is on the Close button, this action closes the panel. Focus returns to the trigger element.
+- If focus is on a content element configured to close the panel (for example, a Submit button), this action closes the panel and returns focus to the trigger.
+
+
+
+
+Escape closes the side panel and returns focus to the trigger element.
+
+
+
diff --git a/site/docs/components/side-panel/examples.mdx b/site/docs/components/side-panel/examples.mdx
new file mode 100644
index 00000000000..4d48603d9af
--- /dev/null
+++ b/site/docs/components/side-panel/examples.mdx
@@ -0,0 +1,64 @@
+---
+title:
+ $ref: ./#/title
+layout: DetailComponent
+sidebar:
+ exclude: true
+data:
+ $ref: ./#/data
+---
+
+## Right side panel
+
+By default, a side panel anchors to the right edge of the screen. Consider using a right‑anchored side panel to surface contextual details and secondary tasks related to the current view—such as item details, comments, history, or quick edits. Always set the panel’s width to suit its content and ensure the main page stays visible and focusable while the panel is open.
+
+
+
+## Left side panel
+
+Consider using a left‑anchored side panel for structure and exploration—such as hierarchical filters, folders, outlines, or multi‑facet filtering.
+
+
+
+## Panel variants
+
+By default the side panel styling uses `variant=”primary”`. Set `variant=”secondary”` for a secondary side panel and `variant=”tertiary”` for a tertiary side panel.
+
+
+
+## In-context triggers
+
+Local affordances are in-context triggers that open side panels for quick tasks, typically placed close to what they affect so settings can be adjusted without losing sight of the primary page.
+
+A side panel can be triggered via a page-level utility, for example a control in the app header that acts on the current page’s primary content:
+
+
+
+A side panel can be also triggered via a control embedded within the main content area, usually scoped to a specific component or subset of data (e.g., a grid toolbar button, row‑level action, card menu):
+
+
+
+## Controlled
+
+You can control the open state of the side panel component by using the `open` and `onOpenChange` props. This allows you to programmatically show or hide the content.
+
+
+
+## Complex layouts
+
+For layouts that need more control over the side panel’s open/close behavior, use manual trigger management instead of SidePanelGroup. This is useful when the trigger and the panel can’t sit together in the component tree, or when open/close behavior needs to be driven by custom logic.
+
+Manual trigger management makes it possible to:
+
+- Place the trigger and the panel in different layout areas.
+- Manage the panel’s open state with application state.
+- Handle multiple panels with custom rules.
+- Integrate with other components that control the panel state.
+
+This approach trades convenience for flexibility: it requires more wiring, but enables more control over placement and behavior.
+
+### How to wire it up
+
+When using manual triggers, pass `open` and `onOpenChange` directly to SidePanel, and set `aria-expanded` and `aria-controls` on the trigger button. Additionally, pass `triggerRef` to the panel so focus returns to the trigger when the panel closes.
+
+
diff --git a/site/docs/components/side-panel/index.mdx b/site/docs/components/side-panel/index.mdx
new file mode 100644
index 00000000000..f3eefc5776c
--- /dev/null
+++ b/site/docs/components/side-panel/index.mdx
@@ -0,0 +1,14 @@
+---
+title: Side panel
+data:
+ description: "A side panel is a persistent, side-by-side workspace that keeps supporting information and controls available while users continue their main task. It’s intended for ongoing parallel work—such as referencing details, inspecting data, filtering results, or editing attributes—where maintaining visibility and context improves accuracy and efficiency."
+ sourceCodeUrl: "https://github.com/jpmorganchase/salt-ds/tree/main/packages/lab/src/side-panel"
+ package:
+ name: "@salt-ds/lab"
+ initialVersion: ""
+ alsoKnownAs: ["Flyout", "Utility panel", "Details panel"]
+ relatedComponents: []
+keywords:
+ $ref: "#/data/alsoKnownAs"
+layout: DetailComponent
+---
diff --git a/site/docs/components/side-panel/usage.mdx b/site/docs/components/side-panel/usage.mdx
new file mode 100644
index 00000000000..e1a1d46110c
--- /dev/null
+++ b/site/docs/components/side-panel/usage.mdx
@@ -0,0 +1,48 @@
+---
+title:
+ $ref: ./#/title
+layout: DetailComponent
+sidebar:
+ exclude: true
+data:
+ $ref: ./#/data
+---
+
+## Using the component
+
+A side panel spans the full height of its container and is always non-modal. This means the content beside it remains interactive when the panel is expanded.
+
+Always choose a width that suits the content in the side panel. Refer to Salt's Forms pattern for information on logical forms width, and the [typography foundation](/salt/foundations/typography) for guidance on paragraph text width.
+
+### When to use
+
+- To view supporting details alongside the primary content (e.g., record details, metadata, history, related items) while keeping the main view visible and interactive.
+- To edit or update information in-context (e.g., update attributes, adjust settings, annotate) while referencing the main view for confirmation.
+- To apply and refine filters (e.g., facets, query parameters) while keeping results visible and interactive next to the filtering controls.
+- To support multi-step workflows (e.g., review → decide → update → confirm) where context and supporting controls must remain available across steps.
+- To keep supplementary information and actions persistently available during longer parallel tasks (e.g., compare items, validate fields, cross-check data).
+
+### When not to use
+
+For a temporary, focused workspace that lets users handle a small, self-contained action. Instead, use [Drawer](/salt/components/drawer).
+For a background that organizes content in an application. Instead, use [Panel](/salt/components/panel).
+
+## Import
+
+To import `SidePanel`, `SidePanelCloseTrigger`, `SidePanelGroup` and `SidePanelTrigger` from the lab Salt package, use:
+
+```js
+import {
+ SidePanel,
+ SidePanelCloseTrigger,
+ SidePanelGroup,
+ SidePanelTrigger,
+} from "@salt-ds/lab";
+```
+
+## Props
+
+
+
+
+
diff --git a/site/src/examples/side-panel/Controlled.tsx b/site/src/examples/side-panel/Controlled.tsx
new file mode 100644
index 00000000000..e4b02cbf36f
--- /dev/null
+++ b/site/src/examples/side-panel/Controlled.tsx
@@ -0,0 +1,55 @@
+import {
+ Button,
+ FlexItem,
+ FlexLayout,
+ H2,
+ StackLayout,
+ Text,
+ useId,
+} from "@salt-ds/core";
+import { CloseIcon } from "@salt-ds/icons";
+import {
+ SidePanel,
+ SidePanelCloseTrigger,
+ SidePanelGroup,
+ SidePanelTrigger,
+} from "@salt-ds/lab";
+import { useState } from "react";
+
+export const Controlled = () => {
+ const [open, setOpen] = useState(false);
+ const headingId = useId();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ setOpen(false)}>
+
+
+