diff --git a/.changeset/sweet-snails-sneeze.md b/.changeset/sweet-snails-sneeze.md
new file mode 100644
index 00000000000..4834bb4bd9f
--- /dev/null
+++ b/.changeset/sweet-snails-sneeze.md
@@ -0,0 +1,42 @@
+---
+"@salt-ds/lab": minor
+---
+
+Added `SidePanel`.
+
+`SidePanel` is a collapsible container that slides in from an edge of its parent, providing supplementary content or controls without disrupting the main layout.
+
+Use `SidePanelProvider` to manage open state, `SidePanelTrigger` to toggle the panel, `SidePanelTitle` provides the accessible name for the panel region automatically and `useSidePanel` to access `setOpen` for programmatic close.
+
+```tsx
+const PanelContent = () => {
+ const { CloseIcon } = useIcon();
+ const { setOpen } = useSidePanel();
+ return (
+
+
+
+
Panel Title
+
+
+
+
+ Panel body content.
+
+
+ );
+};
+
+
+
+
+
+
+;
+```
diff --git a/docs/components/QAContainer.tsx b/docs/components/QAContainer.tsx
index 098146e1256..45e3c88ebd3 100644
--- a/docs/components/QAContainer.tsx
+++ b/docs/components/QAContainer.tsx
@@ -21,13 +21,13 @@ const withBaseName = makePrefixer("saltQAContainer");
export interface QAContainerProps extends ComponentPropsWithoutRef<"div"> {
cols?: number;
densities?: Array<(typeof DensityValues)[number]>;
- height?: number;
+ height?: number | string;
enableStyleInjection?: boolean;
itemPadding?: number;
itemWidthAuto?: boolean;
transposeDensity?: boolean;
vertical?: boolean;
- width?: number;
+ width?: number | string;
}
const BackgroundBlock = ({ children }: ComponentPropsWithoutRef<"div">) => (
@@ -82,8 +82,18 @@ export const QAContainer = ({
}: QAContainerProps) => {
const style = {
"--qaContainer-cols": cols,
- "--qaContainer-height": height === undefined ? undefined : `${height}px`,
- "--qaContainer-width": width === undefined ? undefined : `${width}px`,
+ "--qaContainer-height":
+ height === undefined
+ ? undefined
+ : typeof height === "number"
+ ? `${height}px`
+ : height,
+ "--qaContainer-width":
+ width === undefined
+ ? undefined
+ : typeof width === "number"
+ ? `${width}px`
+ : width,
"--qaContainer-item-padding":
itemPadding === undefined ? undefined : `${itemPadding}px`,
"--qaContainer-item-width": itemWidthAuto ? "auto" : undefined,
diff --git a/packages/lab/src/__tests__/__e2e__/side-panel/SidePanel.cy.tsx b/packages/lab/src/__tests__/__e2e__/side-panel/SidePanel.cy.tsx
new file mode 100644
index 00000000000..d92ed1d0dba
--- /dev/null
+++ b/packages/lab/src/__tests__/__e2e__/side-panel/SidePanel.cy.tsx
@@ -0,0 +1,335 @@
+import {
+ SidePanel,
+ SidePanelContent,
+ SidePanelHeader,
+ SidePanelProvider,
+ SidePanelTitle,
+ SidePanelTrigger,
+} from "@salt-ds/lab";
+import * as sidePanel from "@stories/side-panel/side-panel.stories";
+import { composeStories } from "@storybook/react-vite";
+import { checkAccessibility } from "../../../../../../cypress/tests/checkAccessibility";
+
+const composedStories = composeStories(sidePanel);
+const { Left, Default, ManualTrigger, WithTable, Scrollable, WithNav } =
+ composedStories;
+describe("GIVEN a SidePanel component", () => {
+ checkAccessibility(composedStories);
+
+ describe("Rendering & Position Variants", () => {
+ it("Left story - WHEN opened and closed via button and Escape, THEN lifecycle and ARIA state are correct", () => {
+ cy.mount();
+
+ cy.findByRole("region").should("not.exist");
+
+ cy.findByRole("button", { name: "Open left panel" }).should(
+ "have.attr",
+ "aria-expanded",
+ "false",
+ );
+
+ cy.findByRole("button", { name: "Open left panel" }).click();
+ cy.findByRole("button", { name: "Open left panel" }).should(
+ "have.attr",
+ "aria-expanded",
+ "true",
+ );
+
+ cy.findByRole("region", { name: "Section Title" })
+ .invoke("attr", "id")
+ .then((panelId) => {
+ cy.findByRole("button", { name: "Open left panel" }).should(
+ "have.attr",
+ "aria-controls",
+ panelId,
+ );
+ });
+
+ cy.findByRole("region", { name: "Section Title" }).should("be.visible");
+ cy.findByRole("region").should("have.class", "saltSidePanel-left");
+
+ cy.findByRole("button", { name: "Close" }).click();
+ cy.findByRole("button", { name: "Open left panel" }).should(
+ "have.attr",
+ "aria-expanded",
+ "false",
+ );
+
+ cy.findByRole("region").should("not.exist");
+
+ cy.findByRole("button", { name: "Open left panel" }).click();
+ cy.findByRole("region", { name: "Section Title" }).should("be.visible");
+
+ cy.realPress("Escape");
+ cy.findByRole("region").should("not.exist");
+
+ cy.findByRole("button", { name: "Open left panel" }).click();
+ cy.findByRole("region", { name: "Section Title" }).should("be.visible");
+ cy.findByRole("region").should("have.attr", "role", "region");
+ });
+
+ it("Default story - WHEN opened and closed, THEN ARIA, class, and focus behavior are correct", () => {
+ cy.mount();
+
+ cy.findByRole("button", { name: "Open right panel" }).should(
+ "have.attr",
+ "aria-expanded",
+ "false",
+ );
+
+ cy.findByRole("button", { name: "Open right panel" }).click();
+ cy.findByRole("button", { name: "Open right panel" }).should(
+ "have.attr",
+ "aria-expanded",
+ "true",
+ );
+ cy.findByRole("region")
+ .should("have.class", "saltSidePanel-right")
+ .and("be.visible");
+
+ cy.findByRole("region", { name: "Section Title" })
+ .invoke("attr", "id")
+ .then((panelId) => {
+ cy.findByRole("button", { name: "Open right panel" }).should(
+ "have.attr",
+ "aria-controls",
+ panelId,
+ );
+ });
+
+ cy.findByRole("button", { name: "Close" }).should("have.focus");
+
+ cy.realPress("Escape");
+ cy.findByRole("region").should("not.exist");
+ cy.focused().should("have.text", "Open right panel");
+
+ cy.findByRole("button", { name: "Open right panel" }).click();
+ cy.findByRole("button", { name: "Close" }).should("have.focus");
+
+ cy.findByRole("button", { name: "Close" }).click();
+ cy.findByRole("button", { name: "Open right panel" }).should(
+ "have.attr",
+ "aria-expanded",
+ "false",
+ );
+
+ cy.findByRole("region").should("not.exist");
+ cy.focused().should("have.text", "Open right panel");
+ });
+
+ it("ManualTrigger - WHEN used, THEN aria-expanded and aria-controls are managed correctly", () => {
+ cy.mount();
+
+ cy.findByRole("button", { name: "Toggle left panel" }).should(
+ "have.attr",
+ "aria-expanded",
+ "false",
+ );
+
+ cy.findByRole("button", { name: "Toggle left panel" }).click();
+
+ cy.findByRole("button", { name: "Toggle left panel" }).should(
+ "have.attr",
+ "aria-expanded",
+ "true",
+ );
+
+ cy.findByRole("region", { name: "Left Panel" })
+ .invoke("attr", "id")
+ .then((panelId) => {
+ cy.findByRole("button", { name: "Toggle left panel" }).should(
+ "have.attr",
+ "aria-controls",
+ panelId,
+ );
+ });
+
+ cy.findByRole("region", { name: "Left Panel" }).should("be.visible");
+ });
+ });
+
+ describe("State Management", () => {
+ it("SHOULD manage open state and focus correctly", () => {
+ const onOpenChange = cy.stub().as("onOpenChange");
+
+ cy.mount(
+
+
+
+
+
+
+ Test Panel
+
+
+ Content
+
+ ,
+ );
+
+ cy.findByRole("region").should("not.exist");
+ cy.findByRole("button", { name: "Open Panel" }).click();
+
+ cy.get("@onOpenChange").should("have.been.calledWith", true);
+
+ cy.findByRole("region", { name: "Test Panel" }).should("be.visible");
+ cy.findByRole("button", { name: "Close" }).should("have.focus");
+
+ cy.realPress("Escape");
+
+ cy.get("@onOpenChange").should("have.been.calledWith", false);
+ cy.findByRole("region").should("not.exist");
+ });
+ });
+
+ describe("WithTable", () => {
+ it("WHEN different row Edit buttons are clicked sequentially, THEN details update and table remains visible after close", () => {
+ cy.mount();
+
+ cy.findByRole("table").should("be.visible");
+ cy.findByRole("columnheader", { name: "Name" }).should("be.visible");
+
+ cy.findByRole("button", {
+ name: "Edit details for Alex Morgan",
+ }).click();
+
+ cy.findByRole("region", {
+ name: "Alex Morgan Employee Details",
+ }).should("be.visible");
+ cy.findByRole("region", {
+ name: "Alex Morgan Employee Details",
+ }).within(() => {
+ cy.findByDisplayValue("Alex Morgan").should("be.visible");
+ cy.findByDisplayValue("alex.morgan@example.com").should("be.visible");
+ cy.findByDisplayValue("+1 212 555 0101").should("be.visible");
+ });
+
+ cy.findByRole("button", { name: "Close" }).click();
+ cy.findByRole("region", {
+ name: "Alex Morgan Employee Details",
+ }).should("not.exist");
+ cy.findByRole("table").should("be.visible");
+
+ cy.findByRole("button", {
+ name: "Edit details for Jordan Lee",
+ }).click();
+
+ cy.findByRole("region", {
+ name: "Jordan Lee Employee Details",
+ }).should("be.visible");
+ cy.findByRole("region", {
+ name: "Jordan Lee Employee Details",
+ }).within(() => {
+ cy.findByDisplayValue("Jordan Lee").should("be.visible");
+ cy.findByDisplayValue("jordan.lee@example.com").should("be.visible");
+ });
+ });
+
+ it("WHEN Edit buttons are activated and panel is closed in different ways, THEN focus moves into panel on open and returns to trigger on close", () => {
+ cy.mount();
+
+ // First Edit click — focus moves into the panel
+ cy.findByRole("button", {
+ name: "Edit details for Alex Morgan",
+ }).click();
+ cy.findByRole("region", {
+ name: "Alex Morgan Employee Details",
+ }).should("be.visible");
+ cy.findByRole("button", { name: "Close" }).should("have.focus");
+
+ // Switch rows while panel is open — focus moves into the new panel
+ cy.findByRole("button", {
+ name: "Edit details for Taylor Reed",
+ }).click();
+ cy.findByRole("region", {
+ name: "Taylor Reed Employee Details",
+ }).should("be.visible");
+ cy.findByRole("button", { name: "Close" }).should("have.focus");
+
+ // Close via Escape — focus returns to the trigger
+ cy.realPress("Escape");
+ cy.findByRole("region").should("not.exist");
+ cy.focused().should(
+ "have.attr",
+ "aria-label",
+ "Edit details for Taylor Reed",
+ );
+
+ // Reopen a row — close via Close button — focus returns to the trigger
+ cy.findByRole("button", {
+ name: "Edit details for Alex Morgan",
+ }).click();
+ cy.findByRole("button", { name: "Close" }).click();
+ cy.findByRole("region").should("not.exist");
+ cy.focused().should(
+ "have.attr",
+ "aria-label",
+ "Edit details for Alex Morgan",
+ );
+
+ // Reopen — close via Cancel button — focus returns to the trigger
+ cy.findByRole("button", {
+ name: "Edit details for Jordan Lee",
+ }).click();
+ cy.findByRole("button", { name: "Cancel" }).click();
+ cy.findByRole("region").should("not.exist");
+ cy.focused().should(
+ "have.attr",
+ "aria-label",
+ "Edit details for Jordan Lee",
+ );
+ });
+ });
+
+ describe("Scrollable", () => {
+ it("WHEN panel content scrollability differs, THEN body focusability attributes match configuration", () => {
+ cy.mount();
+
+ cy.findByRole("button", { name: "Open right panel" }).click();
+
+ cy.get(".saltSidePanelContent-body").should("be.visible");
+ cy.get(".saltSidePanelContent-body").should("not.have.attr", "tabindex");
+ cy.get(".saltSidePanelContent-body").should("not.have.attr", "role");
+
+ cy.mount();
+
+ cy.findByRole("button", { name: "Toggle right panel" }).click();
+
+ cy.get(".saltSidePanelContent-body")
+ .should("be.visible")
+ .and("have.attr", "tabindex", "0")
+ .and("have.attr", "role", "region");
+ });
+
+ it("WHEN scrollable panel is opened and closed, THEN focus, visibility, and toggle behavior are correct", () => {
+ cy.mount();
+
+ cy.findByRole("button", { name: "Toggle right panel" }).click();
+
+ cy.findByRole("region", { name: "Section Title" }).should("be.visible");
+ cy.findByRole("region", { name: "Main content" }).should("be.visible");
+
+ cy.findByRole("button", { name: "Close" }).should("have.focus");
+
+ cy.findByRole("button", { name: "Close" }).click();
+ cy.findByRole("region", { name: "Section Title" }).should("not.exist");
+
+ cy.findByRole("button", { name: "Toggle right panel" }).click();
+ cy.findByRole("region", { name: "Section Title" }).should("be.visible");
+ });
+ });
+
+ describe("WithNav", () => {
+ it("WHEN panel is opened and then closed, THEN nav remains visible throughout", () => {
+ cy.mount();
+
+ cy.findByRole("button", { name: "Open side panel" }).click();
+ cy.findByRole("region", { name: "Section Title" }).should("be.visible");
+ cy.findByRole("navigation").should("be.visible");
+
+ cy.findByRole("button", { name: "Close" }).click();
+ cy.findByRole("region", { name: "Section Title" }).should("not.exist");
+ cy.findByRole("navigation").should("be.visible");
+ });
+ });
+});
diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts
index 3662ed000ad..9b375f07bde 100644
--- a/packages/lab/src/index.ts
+++ b/packages/lab/src/index.ts
@@ -53,6 +53,7 @@ export * from "./query-input";
export * from "./rating";
export * from "./responsive";
export * from "./search-input";
+export * from "./side-panel";
export * from "./static-list";
export * from "./system-status";
export * from "./tabs";
diff --git a/packages/lab/src/side-panel/SidePanel.css b/packages/lab/src/side-panel/SidePanel.css
new file mode 100644
index 00000000000..3179ede57fe
--- /dev/null
+++ b/packages/lab/src/side-panel/SidePanel.css
@@ -0,0 +1,101 @@
+.saltSidePanel {
+ background-color: var(--sidePanel-background, var(--salt-container-primary-background));
+ --saltSidePanel-width: 300px;
+ --sidePanel-border: var(--salt-size-fixed-100) var(--salt-borderStyle-solid) var(--sidePanel-borderColor, var(--salt-container-primary-borderColor));
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+}
+
+.saltSidePanel-primary {
+ --sidePanel-background: var(--salt-container-primary-background);
+ --sidePanel-borderColor: var(--salt-container-primary-borderColor);
+}
+
+.saltSidePanel-secondary {
+ --sidePanel-background: var(--salt-container-secondary-background);
+ --sidePanel-borderColor: var(--salt-container-secondary-borderColor);
+}
+
+.saltSidePanel-tertiary {
+ --sidePanel-background: var(--salt-container-tertiary-background);
+ --sidePanel-borderColor: var(--salt-container-tertiary-borderColor);
+}
+
+.saltSidePanel-left {
+ border-right: var(--sidePanel-border);
+}
+.saltSidePanel-right {
+ border-left: var(--sidePanel-border);
+}
+
+.saltSidePanel-none {
+ --sidePanel-background: none;
+ border: none;
+}
+
+.saltSidePanel-none .saltSidePanel-inner {
+ padding: 0;
+}
+
+.saltSidePanel-left,
+.saltSidePanel-right {
+ width: var(--saltSidePanel-width);
+ height: 100%;
+ min-height: 0;
+ align-self: stretch;
+}
+
+.saltSidePanel-enterAnimation,
+.saltSidePanel-exitAnimation {
+ overflow: hidden;
+}
+
+.saltSidePanel-left.saltSidePanel-enterAnimation,
+.saltSidePanel-right.saltSidePanel-enterAnimation {
+ animation: saltSidePanel-expandWidth var(--salt-duration-perceptible) var(--salt-animation-timing-function);
+}
+
+.saltSidePanel-left.saltSidePanel-exitAnimation,
+.saltSidePanel-right.saltSidePanel-exitAnimation {
+ animation: saltSidePanel-collapseWidth var(--salt-duration-perceptible) var(--salt-animation-timing-function) both;
+}
+
+.saltSidePanel-exitAnimation {
+ pointer-events: none;
+}
+
+.saltSidePanel-inner {
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ padding: var(--salt-spacing-300);
+}
+
+.saltSidePanel-left .saltSidePanel-inner,
+.saltSidePanel-right .saltSidePanel-inner {
+ width: var(--saltSidePanel-width);
+ height: 100%;
+}
+
+@keyframes saltSidePanel-expandWidth {
+ from {
+ width: 0;
+ }
+}
+@keyframes saltSidePanel-collapseWidth {
+ to {
+ width: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .saltSidePanel-left.saltSidePanel-enterAnimation,
+ .saltSidePanel-right.saltSidePanel-enterAnimation,
+ .saltSidePanel-left.saltSidePanel-exitAnimation,
+ .saltSidePanel-right.saltSidePanel-exitAnimation {
+ animation: none;
+ }
+}
diff --git a/packages/lab/src/side-panel/SidePanel.tsx b/packages/lab/src/side-panel/SidePanel.tsx
new file mode 100644
index 00000000000..d306d942b09
--- /dev/null
+++ b/packages/lab/src/side-panel/SidePanel.tsx
@@ -0,0 +1,215 @@
+import { FloatingFocusManager } from "@floating-ui/react";
+import { makePrefixer, useFloatingUI, useForkRef, useId } from "@salt-ds/core";
+import { useComponentCssInjection } from "@salt-ds/styles";
+import { useWindow } from "@salt-ds/window";
+import { clsx } from "clsx";
+import {
+ type AnimationEvent,
+ type ComponentProps,
+ type ComponentPropsWithRef,
+ forwardRef,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import { useSidePanelContext } from "./internal";
+import sidePanelCss from "./SidePanel.css";
+
+const withBaseName = makePrefixer("saltSidePanel");
+
+export interface SidePanelProps extends ComponentPropsWithRef<"div"> {
+ /**
+ * Disable the panel's own open/close animation.
+ * Set to `true` when the parent controls sizing and animation (e.g. inside a splitter).
+ * @default false
+ */
+ disableAnimation?: boolean;
+ /**
+ * Edge the panel is anchored to; controls animation direction and divider side.
+ * @default "right"
+ */
+ position?: "right" | "left";
+ /**
+ * Which element receives focus when the panel opens.
+ * Pass a number for the tabbable element index (0 = first), or a ref to a specific element.
+ * Defaults to the side panel close button.
+ */
+ initialFocus?: ComponentProps["initialFocus"];
+ /**
+ * The background color palette. Options are 'primary', 'secondary', 'tertiary' and 'none'.
+ * @default "primary"
+ */
+ variant?: "primary" | "secondary" | "tertiary" | "none";
+}
+
+export const SidePanel = forwardRef(
+ function SidePanel(props, ref) {
+ const {
+ disableAnimation = false,
+ position = "right",
+ initialFocus,
+ variant = "primary",
+ children,
+ id: idProp,
+ className,
+ "aria-labelledby": ariaLabelledBy,
+ ...rest
+ } = props;
+
+ const { openState, floatingRootContext, setFloating, setPanelId, titleId } =
+ useSidePanelContext();
+
+ const id = useId(idProp);
+
+ const [showComponent, setShowComponent] = useState(openState);
+ const [animating, setAnimating] = useState(() => {
+ if (!openState || disableAnimation) return false;
+ // Animate on first mount only when the trigger has focus, indicating a
+ // user-initiated open.
+ const reference = floatingRootContext.elements.reference;
+ if (!(reference instanceof Element)) return false;
+ const activeElement = reference.ownerDocument?.activeElement;
+ return !!activeElement && reference.contains(activeElement);
+ });
+ // On first mount while open, skip moving focus when focus did not come from the trigger.
+ const [skipInitialFocus, setSkipInitialFocus] = useState(() => {
+ if (!openState) return false;
+ const reference = floatingRootContext.elements.reference;
+ if (!(reference instanceof Element)) return true;
+ const activeElement = reference.ownerDocument?.activeElement;
+ return !activeElement || !reference.contains(activeElement);
+ });
+ // Track whether this is the initial render to skip the open animation.
+ const initialRender = useRef(true);
+ const targetWindow = useWindow();
+
+ useComponentCssInjection({
+ testId: "salt-side-panel",
+ css: sidePanelCss,
+ window: targetWindow,
+ });
+
+ const { context } = useFloatingUI({
+ rootContext: floatingRootContext,
+ });
+
+ const handleRef = useForkRef(setFloating, ref);
+
+ const handleAnimationEnd = (event: AnimationEvent) => {
+ if (event.currentTarget !== event.target) return;
+ setAnimating(false);
+ if (!openState) {
+ setShowComponent(false);
+ }
+ };
+
+ useEffect(() => {
+ // Keep this as state (not ref): setPanelId causes a context re-render and
+ // this value is consumed as a prop for initial focus behavior.
+ setPanelId(id);
+
+ return () => {
+ setPanelId(undefined);
+ };
+ }, [id, setPanelId]);
+
+ useEffect(() => {
+ if (!openState) {
+ setSkipInitialFocus(false);
+ }
+ }, [openState]);
+
+ useEffect(() => {
+ if (disableAnimation) {
+ // When animation is disabled, show/hide immediately since there is
+ // no exit animation to wait for before unmounting.
+ setShowComponent(openState);
+ setAnimating(false);
+ initialRender.current = false;
+ return;
+ }
+
+ // Skip animation on initial render when panel is already open
+ // without a trigger interaction (i.e. defaultOpen scenario).
+ if (initialRender.current && openState) {
+ const reference = floatingRootContext.elements.reference;
+ if (!(reference instanceof Element)) {
+ setShowComponent(true);
+ setAnimating(false);
+ initialRender.current = false;
+ return;
+ }
+ }
+
+ const prefersReducedMotion = targetWindow?.matchMedia?.(
+ "(prefers-reduced-motion: reduce)",
+ )?.matches;
+
+ if (openState) {
+ setShowComponent(true);
+ }
+
+ if (prefersReducedMotion) {
+ setAnimating(false);
+ if (!openState) {
+ setShowComponent(false);
+ }
+ } else {
+ setAnimating(true);
+ }
+
+ initialRender.current = false;
+ }, [
+ openState,
+ targetWindow,
+ disableAnimation,
+ floatingRootContext.elements.reference,
+ ]);
+
+ if (!showComponent) return null;
+
+ // `-1` skips initial focus movement but preserves focus guards/return focus handling.
+ const resolvedInitialFocus = skipInitialFocus ? -1 : (initialFocus ?? 0);
+
+ const panelDiv = (
+
+
+
+
+ {Array.from({ length: 20 }, (_, i) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: Example-only static placeholder items
+
+
+ Content card {i + 1} — This card is part of the main
+ scrollable content area.
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/site/docs/components/side-panel/accessibility.mdx b/site/docs/components/side-panel/accessibility.mdx
new file mode 100644
index 00000000000..a313ea8579c
--- /dev/null
+++ b/site/docs/components/side-panel/accessibility.mdx
@@ -0,0 +1,46 @@
+---
+title:
+ $ref: ./#/title
+layout: DetailComponent
+sidebar:
+ exclude: true
+data:
+ $ref: ./#/data
+---
+
+## Best practices
+
+**Provide a clear accessible name for the `SidePanel`**
+
+When you use `SidePanelTitle` inside `SidePanelHeader`, the accessible name for the panel region is provided automatically.
+
+If you are not using `SidePanelTitle`, you must provide an accessible name explicitly using `aria-labelledby` (preferred when a visible heading exists) or `aria-label` (when no visible heading is available).
+
+## 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..7875f2206d7
--- /dev/null
+++ b/site/docs/components/side-panel/examples.mdx
@@ -0,0 +1,66 @@
+---
+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, `variant="tertiary"` for a tertiary side panel, or `variant="none"` to remove the default styling.
+
+
+
+## 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 also be 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):
+
+
+
+## Manual Trigger
+
+For layouts that need more control over the side panel’s open/close behavior, use manual trigger management instead of SidePanelProvider. 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.
+
+
+
+## Side panels between content areas
+
+Side Panels can sit between content areas to create a multi-panel layout. When using multiple panels, ensure that the layout remains clear and that users can easily distinguish between different panels and their purposes.
+
+
+
+## Scrollable content within panels
+
+Side Panels can contain scrollable content and still keep the main page visible and interactive. When using scrollable content within a panel, ensure the title and close button remain visible and accessible to users.
+
+
diff --git a/site/docs/components/side-panel/index.mdx b/site/docs/components/side-panel/index.mdx
new file mode 100644
index 00000000000..5e21923cc6a
--- /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: ["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..c851ab4eed8
--- /dev/null
+++ b/site/docs/components/side-panel/usage.mdx
@@ -0,0 +1,204 @@
+---
+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 the [Forms pattern](/salt/patterns/forms) 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 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` and related components from the lab Salt package, use:
+
+```js
+import {
+ SidePanel,
+ SidePanelContent,
+ SidePanelHeader,
+ SidePanelProvider,
+ SidePanelTitle,
+ SidePanelTrigger,
+} from "@salt-ds/lab";
+```
+
+### SidePanelProvider
+
+`SidePanelProvider` wraps both the trigger and the panel. It manages the open/closed state and coordinates ARIA attributes, focus management and keyboard dismissal between them. Place it around every group of trigger and panel elements that should share state.
+
+```jsx
+{/* trigger and panel go here */}
+```
+
+Use the `defaultOpen` prop to render the panel open on first mount, or pass `open` and `onOpenChange` for fully controlled state.
+
+### SidePanelTrigger
+
+`SidePanelTrigger` wraps the element that opens and closes the panel. It accepts a single child element (typically a `Button`) and automatically applies `aria-expanded`, `aria-controls`, and a click handler to toggle the panel.
+
+```jsx
+
+
+
+```
+
+`SidePanelTrigger` does not render a wrapper element. It clones its child and merges the required props onto it, so your button remains the direct flex or layout child.
+
+### SidePanel
+
+`SidePanel` is the panel surface itself. It renders as a `region` landmark, manages its enter/exit animation, and moves focus into the panel when it opens. Use the `position` prop to anchor the panel to the `"left"` or `"right"` edge of its container (defaults to `"right"`), and the `variant` prop to set its background color (`"primary"`, `"secondary"`, or `"tertiary"`).
+
+When you use `SidePanelTitle` inside `SidePanelHeader`, the accessible name for the panel region is provided automatically. If you are not using `SidePanelTitle`, you must provide an accessible name via `aria-labelledby` or `aria-label`.
+
+```jsx
+{/* header and content */}
+```
+
+### SidePanelHeader
+
+`SidePanelHeader` is a layout wrapper for the panel header area. It is intended to contain a `SidePanelTitle` and a close button.
+
+```jsx
+const { CloseIcon } = useIcon();
+const { setOpen } = useSidePanel();
+
+
+
+
Panel Title
+
+
+;
+```
+
+### SidePanelTitle
+
+`SidePanelTitle` provides the accessible name for the side panel region. It generates a unique ID internally and registers it with the context so that `SidePanel` can use it for `aria-labelledby` automatically. Place your heading element (e.g. `
`) inside it.
+
+```jsx
+
+
Section Title
+
+```
+
+### SidePanelContent
+
+`SidePanelContent` is a scrollable body wrapper. When the content overflows, it automatically becomes a focusable `region` with an accessible name derived from the `SidePanelTitle`.
+
+```jsx
+
+ Panel body content goes here.
+
+```
+
+If you need a custom layout or close behavior, you can skip `SidePanelContent` and compose your own body using `useSidePanel`.
+
+### Putting it together
+
+A minimal side panel combines all parts inside a flex container:
+
+```jsx
+const { CloseIcon } = useIcon();
+const { setOpen } = useSidePanel();
+
+
+
+
+
+
+
+
+
+
+
+
+
Details
+
+
+
+
+ Side panel content goes here.
+
+
+
+;
+```
+
+### Manual trigger
+
+When `SidePanelTrigger` doesn't fit your layout—for example, when multiple buttons in a table each open the same panel—you can build a manual trigger using `useSidePanel`. Call `setOpen`, `triggerRef`, and spread `getTriggerProps` onto your element to maintain the correct ARIA attributes and focus return behavior.
+
+```jsx
+const { setOpen, triggerRef, getTriggerProps, panelId, openState } =
+ useSidePanel();
+
+;
+```
+
+## Props
+
+### SidePanel
+
+
+
+### SidePanelProvider
+
+
+
+### SidePanelTrigger
+
+
+
+### SidePanelHeader
+
+
+
+### SidePanelTitle
+
+
+
+### SidePanelContent
+
+
diff --git a/site/src/examples/side-panel/ContentExample.tsx b/site/src/examples/side-panel/ContentExample.tsx
new file mode 100644
index 00000000000..abb6c0cdcad
--- /dev/null
+++ b/site/src/examples/side-panel/ContentExample.tsx
@@ -0,0 +1,41 @@
+import type { ReactNode } from "react";
+
+export const ContentExample = ({ children }: { children?: ReactNode }) => (
+