diff --git a/src/panel-layout/styles.scss b/src/panel-layout/styles.scss
index 62e17be8c4..fcd953c53a 100644
--- a/src/panel-layout/styles.scss
+++ b/src/panel-layout/styles.scss
@@ -30,6 +30,17 @@
.panel {
display: flex;
flex-shrink: 0;
+ @include styles.with-motion {
+ transition: inline-size awsui.$motion-duration-complex awsui.$motion-easing-responsive;
+ }
+ &-resizing {
+ @include styles.with-motion {
+ transition: none;
+ }
+ > .panel-content {
+ pointer-events: none;
+ }
+ }
> .handle {
display: flex;
align-items: center;
@@ -59,6 +70,9 @@
.display-panel-only > & {
display: none;
}
+ .panel-resizing ~ & {
+ pointer-events: none;
+ }
@include focus-visible.when-visible {
@include container-inner-focus-ring;
}
diff --git a/src/side-navigation/implementation.tsx b/src/side-navigation/implementation.tsx
index 4b2d11a066..80c73065ce 100644
--- a/src/side-navigation/implementation.tsx
+++ b/src/side-navigation/implementation.tsx
@@ -24,6 +24,9 @@ export function SideNavigationImplementation({
items = [],
onFollow,
onChange,
+ expandIconPosition = 'start',
+ collapsed = false,
+ variant = 'default',
__internalRootRef,
...props
}: SideNavigationInternalProps) {
@@ -66,9 +69,15 @@ export function SideNavigationImplementation({
ref={__internalRootRef}
>
{header && (
-
+
)}
- {itemsControl &&
{itemsControl}
}
+ {!collapsed && itemsControl &&
{itemsControl}
}
{items && (
)}
diff --git a/src/side-navigation/index.tsx b/src/side-navigation/index.tsx
index ea6ff5c36c..f0a7ccfd1d 100644
--- a/src/side-navigation/index.tsx
+++ b/src/side-navigation/index.tsx
@@ -15,8 +15,16 @@ import analyticsSelectors from './analytics-metadata/styles.css.js';
export { SideNavigationProps };
-export default function SideNavigation({ items = [], ...props }: SideNavigationProps) {
- const internalProps = useBaseComponent('SideNavigation');
+export default function SideNavigation({
+ items = [],
+ expandIconPosition = 'start',
+ collapsed = false,
+ variant = 'default',
+ ...props
+}: SideNavigationProps) {
+ const internalProps = useBaseComponent('SideNavigation', {
+ props: { expandIconPosition, collapsed, variant },
+ });
const componentAnalyticMetadata: GeneratedAnalyticsMetadataSideNavigationComponent = {
name: 'awsui.SideNavigation',
@@ -30,6 +38,9 @@ export default function SideNavigation({ items = [], ...props }: SideNavigationP
{...props}
{...internalProps}
items={items}
+ expandIconPosition={expandIconPosition}
+ collapsed={collapsed}
+ variant={variant}
{...getAnalyticsMetadataAttribute({ component: componentAnalyticMetadata })}
/>
);
diff --git a/src/side-navigation/interfaces.tsx b/src/side-navigation/interfaces.tsx
index c3241d9bc5..e6f378b026 100644
--- a/src/side-navigation/interfaces.tsx
+++ b/src/side-navigation/interfaces.tsx
@@ -52,6 +52,7 @@ export interface SideNavigationProps extends BaseComponentProps {
* - `externalIconAriaLabel` (string) - Adds an aria-label to the external icon.
* - `info` (ReactNode) - Enables you to display content next to the link. Although it is technically possible to insert any content,
* our UX guidelines allow only to add a Badge and/or a "New" label.
+ * - `icon` (ReactNode) - Optional content rendered before the link text. Accepts any React node (for example, an `
` component or a custom `
`).
*
* #### Divider
* Object that represents a horizontal divider between navigation content.
@@ -65,6 +66,7 @@ export interface SideNavigationProps extends BaseComponentProps {
* - `items` (array) - Specifies the content of the section. You can use any valid item from this list.
* Although there is no technical limitation to the nesting level,
* our UX recommendation is to use only one level.
+ * - `icon` (ReactNode) - Optional content rendered before the section title. Accepts any React node.
*
* #### Section Group
* Aggregates a set of items that are conceptually related to each other, and can be displayed under a single heading to provide further organization.
@@ -72,6 +74,7 @@ export interface SideNavigationProps extends BaseComponentProps {
* - `type`: `'section-group'`.
* - `title` (string) - Specifies the text to display as a title of the section group.
* - `items` (array) - Specifies the content of the section header group. You can use `Section`, `Link`, `LinkGroup`, `ExpandableLinkGroup`.
+ * - `icon` (ReactNode) - Optional content rendered before the section group title. Accepts any React node.
*
* #### LinkGroup
* Object that represents a group of links.
@@ -83,6 +86,7 @@ export interface SideNavigationProps extends BaseComponentProps {
* - `items` (array) - Specifies the content of the section. You can use any valid item from this list.
* Although there is no technical limitation to the nesting level,
* our UX recommendation is to use only one level.
+ * - `icon` (ReactNode) - Optional content rendered before the link group text. Accepts any React node.
*
* #### ExpandableLinkGroup
*
@@ -95,6 +99,7 @@ export interface SideNavigationProps extends BaseComponentProps {
* - `items` (array) - Specifies the content of the section. You can use any valid item from this list.
* Although there is no technical limitation to the nesting level,
* our UX recommendation is to use only one level.
+ * - `icon` (ReactNode) - Optional content rendered before the expandable link group text. Accepts any React node.
*/
items?: ReadonlyArray;
@@ -124,12 +129,47 @@ export interface SideNavigationProps extends BaseComponentProps {
* upon changing the `activeHref` property, this event isn't raised.
*/
onChange?: NonCancelableEventHandler;
+
+ /**
+ * Controls the placement of the expand/collapse icon for `Section` and
+ * `ExpandableLinkGroup` items.
+ * - `start` (default) - The icon is rendered before the section title.
+ * - `end` - The icon is rendered after the section title and pulled to the
+ * inline-end of a full-width header.
+ */
+ expandIconPosition?: SideNavigationProps.ExpandIconPosition;
+
+ /**
+ * If true, the navigation is rendered in a compact, icon-only state:
+ * - Item text labels and the header title are hidden.
+ * - Section, `Section group`, `Link group`, and `Expandable link group`
+ * children are not rendered.
+ * - `Items control` is not rendered.
+ * - Items without an `icon` are not rendered. Provide an icon for any
+ * item that should remain visible while collapsed.
+ *
+ * @defaultValue false
+ */
+ collapsed?: boolean;
+
+ /**
+ * Visual variant of the navigation:
+ * - `default` (default) - The active item uses a text-highlight style.
+ * - `highlighted` - The active item uses a background fill with rounded
+ * corners. Each item is padded so the background fills a comfortable area
+ * around the label and icon.
+ */
+ variant?: SideNavigationProps.Variant;
}
export namespace SideNavigationProps {
+ export type ExpandIconPosition = 'start' | 'end';
+ export type Variant = 'default' | 'highlighted';
+
export interface Logo {
- src: string;
+ src?: string;
alt?: string;
+ svg?: React.ReactNode;
}
export interface Header {
text?: string;
@@ -148,6 +188,7 @@ export namespace SideNavigationProps {
external?: boolean;
externalIconAriaLabel?: string;
info?: React.ReactNode;
+ icon?: React.ReactNode;
}
export interface Section {
@@ -155,12 +196,14 @@ export namespace SideNavigationProps {
text: string;
items: ReadonlyArray- ;
defaultExpanded?: boolean;
+ icon?: React.ReactNode;
}
export interface SectionGroup {
type: 'section-group';
title: string;
items: ReadonlyArray
;
+ icon?: React.ReactNode;
}
export interface LinkGroup {
type: 'link-group';
@@ -168,6 +211,7 @@ export namespace SideNavigationProps {
href: string;
info?: React.ReactNode;
items: ReadonlyArray- ;
+ icon?: React.ReactNode;
}
export interface ExpandableLinkGroup {
@@ -176,6 +220,7 @@ export namespace SideNavigationProps {
href: string;
items: ReadonlyArray
- ;
defaultExpanded?: boolean;
+ icon?: React.ReactNode;
}
export type Item = Divider | Link | Section | LinkGroup | ExpandableLinkGroup | SectionGroup;
diff --git a/src/side-navigation/parts.tsx b/src/side-navigation/parts.tsx
index 484d42494a..0151d42a2a 100644
--- a/src/side-navigation/parts.tsx
+++ b/src/side-navigation/parts.tsx
@@ -1,6 +1,6 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
-import React, { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
+import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import clsx from 'clsx';
import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata';
@@ -13,6 +13,7 @@ import InternalIcon from '../icon/internal';
import { isPlainLeftClick, NonCancelableCustomEvent } from '../internal/events';
import { useVisualRefresh } from '../internal/hooks/use-visual-mode';
import { checkSafeUrl } from '../internal/utils/check-safe-url';
+import Tooltip from '../tooltip/internal';
import { GeneratedAnalyticsMetadataSideNavigationClick } from './analytics-metadata/interfaces';
import { SideNavigationProps } from './interfaces';
import { hasActiveLink } from './util';
@@ -33,13 +34,18 @@ interface BaseItemComponentProps {
event: React.SyntheticEvent | Event
) => void;
position?: string;
+ expandIconPosition?: SideNavigationProps.ExpandIconPosition;
+ collapsed?: boolean;
+ // Renamed from `variant` (which is already used for the list variant) so we
+ // can plumb the SideNavigation public `variant` prop down to each link.
+ highlightVariant?: SideNavigationProps.Variant;
}
interface HeaderProps extends BaseItemComponentProps {
definition: SideNavigationProps.Header;
}
-export function Header({ definition, activeHref, fireFollow }: HeaderProps) {
+export function Header({ definition, activeHref, fireFollow, collapsed }: HeaderProps) {
checkSafeUrl('SideNavigation', definition.href);
const onClick = useCallback(
(event: React.MouseEvent) => {
@@ -62,25 +68,38 @@ export function Header({ definition, activeHref, fireFollow }: HeaderProps) {
return (
<>
-
+
@@ -106,6 +125,9 @@ export function NavigationItemsList({
fireChange,
fireFollow,
position = '',
+ expandIconPosition,
+ collapsed,
+ highlightVariant,
}: NavigationItemsListProps) {
const lists: Array- = [];
let currentListIndex = 0;
@@ -117,13 +139,21 @@ export function NavigationItemsList({
items.forEach((item, index) => {
const itemid = index + 1;
const itemPosition = `${position ? `${position},` : ''}${itemid}`;
+ // In collapsed mode, only selectable items are visible (links, link-groups, ELGs).
+ // Sections and section-groups are non-selectable containers — skip them.
+ if (collapsed && (item.type === 'section' || item.type === 'section-group')) {
+ return;
+ }
+ if (collapsed && item.type !== 'divider' && !item.icon) {
+ return;
+ }
switch (item.type) {
case 'divider': {
const dividerIndex = lists.length;
lists[dividerIndex] = {
element: (
),
};
@@ -137,13 +167,19 @@ export function NavigationItemsList({
case 'link': {
lists[currentListIndex].items?.push({
element: (
-
+
),
@@ -153,7 +189,11 @@ export function NavigationItemsList({
case 'section': {
lists[currentListIndex].items?.push({
element: (
-
+
),
@@ -170,13 +213,20 @@ export function NavigationItemsList({
case 'section-group': {
lists[currentListIndex].items?.push({
element: (
-
+
),
@@ -186,13 +236,20 @@ export function NavigationItemsList({
case 'link-group': {
lists[currentListIndex].items?.push({
element: (
-
+
),
@@ -202,7 +259,11 @@ export function NavigationItemsList({
case 'expandable-link-group': {
lists[currentListIndex].items?.push({
element: (
-
+
),
@@ -219,15 +283,32 @@ export function NavigationItemsList({
}
});
+ // In collapsed mode, skip empty item segments and deduplicate consecutive dividers.
+ const filteredLists = collapsed
+ ? lists.filter((list, index) => {
+ if (list.items) {
+ return list.items.length > 0;
+ }
+ // Divider — skip if preceded by another divider or empty segment.
+ const prevVisible = lists
+ .slice(0, index)
+ .reverse()
+ .find(l => !l.items || l.items.length > 0);
+ return !prevVisible || (prevVisible.items !== undefined && prevVisible.items.length > 0);
+ })
+ : lists;
+
return (
<>
- {lists.map((list, index) => {
+ {filteredLists.map((list, index) => {
if (!list.items || list.items.length === 0) {
return (
{list.element}
@@ -239,6 +320,9 @@ export function NavigationItemsList({
key={`list-${index}`}
className={clsx(styles.list, styles[`list-variant-${list.listVariant}`], {
[styles['list-variant-root--first']]: list.listVariant === 'root' && index === 0,
+ [styles['list-variant-root--symmetric']]:
+ list.listVariant === 'root' && (collapsed || expandIconPosition === 'end'),
+ [styles[`expand-icon-end`]]: expandIconPosition === 'end',
})}
>
{list.items.map(item => item.element)}
@@ -253,12 +337,13 @@ export function NavigationItemsList({
interface DividerProps {
variant: 'default' | 'header';
isPresentational?: boolean;
+ collapsed?: boolean;
}
-function Divider({ variant = 'default', isPresentational = false }: DividerProps) {
+function Divider({ variant = 'default', isPresentational = false, collapsed }: DividerProps) {
return (
);
@@ -268,10 +353,61 @@ interface LinkProps extends BaseItemComponentProps {
definition: SideNavigationProps.Link;
}
-function Link({ definition, activeHref, fireFollow, position }: LinkProps) {
+interface ItemIconProps extends React.HTMLAttributes
{
+ icon: React.ReactNode;
+ collapsed?: boolean;
+}
+
+const ItemIcon = React.forwardRef(function ItemIcon(
+ { icon, collapsed, className, ...rest },
+ ref
+) {
+ if (!icon) {
+ return null;
+ }
+ return (
+
+ {icon}
+
+ );
+});
+
+// Manages a tooltip that shows the item's text label on focus or hover.
+// Used in the collapsed state, where the visible labels are hidden, to give
+// pointer and keyboard users a way to identify each item without relying on
+// their browser's native title popup.
+function useCollapsedTooltip(label: React.ReactNode) {
+ const [show, setShow] = useState(false);
+ const triggerRef = useRef(null);
+
+ const triggerProps = {
+ onFocus: () => setShow(true),
+ onBlur: () => setShow(false),
+ onMouseEnter: () => setShow(true),
+ onMouseLeave: () => setShow(false),
+ };
+
+ const tooltip = show ? (
+ triggerRef.current} content={label} position="right" onEscape={() => setShow(false)} />
+ ) : null;
+
+ return { triggerRef, triggerProps, tooltip };
+}
+
+function Link({ definition, activeHref, fireFollow, position, collapsed, highlightVariant }: LinkProps) {
checkSafeUrl('SideNavigation', definition.href);
const isActive = definition.href === activeHref;
const i18n = useInternalI18n('link');
+ const collapsedTooltip = useCollapsedTooltip(definition.text);
const onClick = useCallback(
(event: React.MouseEvent) => {
@@ -297,22 +433,33 @@ function Link({ definition, activeHref, fireFollow, position }: LinkProps) {
return (
<>
- {definition.text}
- {definition.external && (
+
+ {!collapsed && {definition.text} }
+ {!collapsed && definition.external && (
)}
- {definition.info && {definition.info} }
+ {!collapsed && definition.info && (
+ {definition.info}
+ )}
+ {collapsed && collapsedTooltip.tooltip}
>
);
}
@@ -322,9 +469,20 @@ interface SectionProps extends BaseItemComponentProps {
variant: 'section' | 'section-group' | 'link-group' | 'expandable-link-group' | 'root';
}
-function Section({ definition, activeHref, fireFollow, fireChange, variant, position }: SectionProps) {
+function Section({
+ definition,
+ activeHref,
+ fireFollow,
+ fireChange,
+ variant,
+ position,
+ expandIconPosition,
+ collapsed,
+ highlightVariant,
+}: SectionProps) {
const [expanded, setExpanded] = useState(definition.defaultExpanded ?? true);
const isVisualRefresh = useVisualRefresh();
+ const collapsedTooltip = useCollapsedTooltip(definition.text);
const onExpandedChange = useCallback(
(e: NonCancelableCustomEvent) => {
@@ -338,6 +496,23 @@ function Section({ definition, activeHref, fireFollow, fireChange, variant, posi
setExpanded(definition.defaultExpanded ?? true);
}, [definition]);
+ if (collapsed) {
+ return (
+ <>
+
+ {collapsedTooltip.tooltip}
+ >
+ );
+ }
+
+ const isNestedInSectionGroup = variant === 'section-group';
+
return (
+
+ {definition.text}
+
+ ) : (
+ definition.text
+ )
+ }
+ __expandIconPosition={expandIconPosition}
>
);
@@ -366,10 +554,36 @@ interface SectionGroupProps extends BaseItemComponentProps {
definition: SideNavigationProps.SectionGroup;
}
-function SectionGroup({ definition, activeHref, fireFollow, fireChange, position }: SectionGroupProps) {
+function SectionGroup({
+ definition,
+ activeHref,
+ fireFollow,
+ fireChange,
+ position,
+ expandIconPosition,
+ collapsed,
+ highlightVariant,
+}: SectionGroupProps) {
+ const collapsedTooltip = useCollapsedTooltip(definition.title);
+
+ if (collapsed) {
+ return (
+ <>
+
+ {collapsedTooltip.tooltip}
+ >
+ );
+ }
return (
+
{definition.title}
);
@@ -388,26 +604,47 @@ interface LinkGroupProps extends BaseItemComponentProps {
definition: SideNavigationProps.LinkGroup;
}
-function LinkGroup({ definition, activeHref, fireFollow, fireChange, position }: LinkGroupProps) {
+function LinkGroup({
+ definition,
+ activeHref,
+ fireFollow,
+ fireChange,
+ position,
+ expandIconPosition,
+ collapsed,
+ highlightVariant,
+}: LinkGroupProps) {
checkSafeUrl('SideNavigation', definition.href);
return (
<>
fireFollow(definition, event)}
fireChange={fireChange}
activeHref={activeHref}
position={position}
+ collapsed={collapsed}
+ highlightVariant={highlightVariant}
/>
-
+ {!collapsed && (
+
+ )}
>
);
}
@@ -424,6 +661,9 @@ function ExpandableLinkGroup({
activeHref,
variant,
position,
+ expandIconPosition,
+ collapsed,
+ highlightVariant,
}: ExpandableLinkGroupProps) {
// Check whether the definition contains an active link and memoize it to avoid
// rechecking every time.
@@ -468,24 +708,43 @@ function ExpandableLinkGroup({
}
};
+ if (collapsed) {
+ return (
+
+ );
+ }
+
return (
}
+ __expandIconPosition={expandIconPosition}
>
);
diff --git a/src/side-navigation/styles.scss b/src/side-navigation/styles.scss
index 1595911163..e347cccc2a 100644
--- a/src/side-navigation/styles.scss
+++ b/src/side-navigation/styles.scss
@@ -7,11 +7,37 @@
@use '../internal/styles/tokens' as awsui;
@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible;
+// ==========================================================================
+// Item sizing — single source of truth for navigation density.
+// Adjust $item-height to scale all items uniformly.
+// ==========================================================================
+$item-icon-text-gap: awsui.$space-xs;
+$item-height: awsui.$size-side-navigation-item-height;
+$item-margin-block: awsui.$space-side-navigation-item-gap;
+$item-collapsed-gap: awsui.$space-side-navigation-item-collapsed-gap;
+$item-padding-inline: awsui.$space-xs;
+$item-indent: calc(16px + #{$item-icon-text-gap} - #{$item-padding-inline});
+$expandable-icon-negative-margin: 19px;
+
+@mixin item-radius {
+ border-start-start-radius: awsui.$border-radius-item;
+ border-start-end-radius: awsui.$border-radius-item;
+ border-end-start-radius: awsui.$border-radius-item;
+ border-end-end-radius: awsui.$border-radius-item;
+}
+
.root {
@include styles.styles-reset;
@include styles.text-wrapping;
}
+.with-toolbar {
+ /* Structural class — parent context for toolbar margin resets. */
+}
+
+// ==========================================================================
+// Header
+// ==========================================================================
.header {
@include styles.font-panel-header;
margin-block: 0;
@@ -20,13 +46,19 @@
padding-inline-start: awsui.$space-panel-nav-left;
// Additional xl space to prevent text from overlapping the close button.
padding-inline-end: calc(#{awsui.$space-scaled-xxl} + #{awsui.$space-xl});
+
+ &--collapsed {
+ padding-inline: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
}
.header-link {
@include styles.font-panel-header;
color: awsui.$color-text-heading-default;
min-block-size: awsui.$font-panel-header-line-height;
-
display: flex;
&--has-logo {
@@ -49,6 +81,9 @@
}
}
+// ==========================================================================
+// List structure
+// ==========================================================================
.items-control {
padding-inline: awsui.$space-l;
}
@@ -71,93 +106,216 @@
margin-inline: 0;
padding-block: 0;
padding-inline-end: 0;
- padding-inline-start: awsui.$space-l;
+ padding-inline-start: $item-indent;
}
.list-variant-root {
margin-block: 0;
margin-inline: 0;
padding-block: 0;
- padding-inline-start: awsui.$space-panel-nav-left;
- padding-inline-end: awsui.$space-panel-side-right;
+ padding-inline-start: calc(#{awsui.$space-panel-nav-left} - #{$item-padding-inline});
+ padding-inline-end: calc(#{awsui.$space-panel-side-right} - #{$item-padding-inline});
&--first {
margin-block-start: 0;
}
+
+ &--symmetric {
+ padding-inline-start: calc(#{awsui.$space-panel-side-right} - #{$item-padding-inline});
+ }
+
+ &--collapsed {
+ padding-inline-start: 0;
+ padding-inline-end: 0;
+ }
}
.list-variant-expandable-link-group {
- padding-inline-start: awsui.$space-xxxl;
+ // 2 levels of nested indent (header + child) + icon/text offset.
+ padding-inline-start: calc(#{$item-indent} + #{$expandable-icon-negative-margin});
+ &.expand-icon-end {
+ padding-inline-start: $item-indent;
+ }
+}
+
+.list-variant-section {
+ padding-inline-start: calc(#{$expandable-icon-negative-margin} - #{$item-padding-inline});
+ &.expand-icon-end {
+ padding-inline-start: 0;
+ margin-inline-start: calc(-1 * #{$item-padding-inline});
+ }
}
+.list-variant-link-group {
+ padding-inline-start: $item-indent;
+}
+
+.list-variant-section-group {
+ margin-block: 0;
+ margin-inline: calc(-1 * #{$item-padding-inline});
+ padding-block: 0;
+ padding-inline: 0;
+
+ &-collapsed {
+ margin-block: awsui.$space-scaled-xxxl;
+ margin-inline: 0;
+ padding-inline-start: 0;
+ padding-inline-end: 0;
+ }
+}
+
+// ==========================================================================
+// List items
+// ==========================================================================
.list-item {
- margin-block: awsui.$space-scaled-xs;
margin-inline: 0;
+ margin-block: $item-margin-block;
padding-block: 0;
- padding-inline: 0;
+ padding-inline: $item-padding-inline;
list-style: none;
-
+ @include styles.with-motion {
+ transition: margin awsui.$motion-duration-expressive awsui.$motion-easing-responsive;
+ }
// Remove margin from first item in side nav, outer block margins are covered by list-container
.list-variant-root--first > &:first-child {
margin-block-start: 0px;
}
+
+ &--collapsed {
+ margin-block: $item-collapsed-gap;
+ display: flex;
+ justify-content: center;
+ padding-block: 0;
+ padding-inline: 0;
+ @include styles.with-motion {
+ transition: margin awsui.$motion-duration-expressive awsui.$motion-easing-responsive;
+ }
+ }
}
+// ==========================================================================
+// Sections and expandable link groups (InternalExpandableSection)
+// ==========================================================================
.section,
.expandable-link-group {
- margin-inline-start: calc(-1 * #{awsui.$space-l});
+ margin-inline-start: calc(-1 * #{$expandable-icon-negative-margin});
+ /* stylelint-disable-next-line selector-max-type */
+ & > div {
+ padding-block: 0;
+ padding-inline: 0;
+ &:first-child {
+ display: grid;
+ align-items: center;
+ min-block-size: $item-height;
+ }
+ }
- &--no-ident {
+ &--no-ident,
+ &--expand-icon-end {
margin-inline-start: 0;
}
}
+.expandable-link-group > div:first-child {
+ @include item-radius;
+ display: flex;
+ padding-inline: $item-padding-inline;
+ margin-inline: calc(-1 * #{$item-padding-inline});
+}
+
+.expandable-link-group--expand-icon-end {
+ margin-inline-end: calc(-2 * #{$item-padding-inline});
+}
+
+.expandable-link-group-active > div:first-child {
+ background-color: color-mix(in srgb, #{awsui.$color-item-selected} 10%, transparent);
+}
+
.section {
margin-block: calc(#{awsui.$space-scaled-2x-l} - #{awsui.$border-divider-section-width});
&.refresh {
- margin-block: calc(#{awsui.$space-scaled-2x-m} - #{awsui.$border-divider-section-width});
+ margin-block: calc(#{awsui.$space-scaled-s} - #{awsui.$border-divider-section-width});
}
// Remove margin from section if it is the first item in side nav to prevent double margin stacking
.list-variant-root--first > .list-item:first-child > & {
margin-block-start: 0px;
}
- // HACK: Remove padding from section header and content to rely on margin collapsing rules.
- /* stylelint-disable-next-line selector-max-type */
- > div {
- padding-block: 0;
- padding-inline: 0;
- }
}
-.list-variant-section-group {
- margin-block: 0;
+// ==========================================================================
+// Section groups
+// ==========================================================================
+.section-group {
+ @include styles.font-heading-s;
+ margin-block: calc(#{awsui.$space-scaled-2x-l} - #{awsui.$border-divider-section-width});
margin-inline: 0;
- padding-block: 0;
- padding-inline: 0;
+
+ .list-variant-root--first > .list-item:first-child > & {
+ margin-block-start: 0px;
+ }
}
-.section-group {
- @include styles.font-heading-m;
+.section-group-title {
+ display: flex;
+ min-block-size: $item-height;
+ align-items: center;
margin-block: 0;
- margin-inline: 0;
+ padding-block: awsui.$space-scaled-xxs;
+ &--eyebrow {
+ @include styles.font-body-s;
+ text-transform: uppercase;
+ letter-spacing: 0.35px;
+ color: awsui.$color-text-body-secondary;
+ }
}
-.section-group-title {
- /* used in test-utils */
+.section-header-text {
+ display: inline-flex;
}
+// ==========================================================================
+// Links — all links use $item-height for consistent row sizing and collapse icon size.
+// ==========================================================================
.link {
@include styles.font-body-m;
color: awsui.$color-text-body-secondary;
+ display: inline-flex;
+ min-block-size: $item-height;
+ min-inline-size: $item-height;
+ padding-inline: $item-padding-inline;
+ margin-inline: calc(-1 * #{$item-padding-inline});
+ align-items: center;
+ @include item-radius;
font-weight: styles.$font-weight-normal;
-webkit-font-smoothing: auto;
-moz-osx-font-smoothing: auto;
-}
+ @include styles.with-motion {
+ transition:
+ background-color awsui.$motion-duration-expressive awsui.$motion-easing-responsive,
+ color awsui.$motion-duration-expressive awsui.$motion-easing-expressive;
+ }
+
+ &--active {
+ font-weight: awsui.$font-wayfinding-link-active-weight;
+ @include styles.font-smoothing;
+ color: awsui.$color-text-accent;
+ &.link--pill {
+ background-color: color-mix(in srgb, #{awsui.$color-background-item-selected} 50%, transparent);
+ color: awsui.$color-item-selected;
+ }
+ }
-.link-active {
- font-weight: awsui.$font-wayfinding-link-active-weight;
- @include styles.font-smoothing;
- color: awsui.$color-text-accent;
+ &--collapsed {
+ padding-block: 0;
+ padding-inline: 0;
+ margin-inline: 0;
+ justify-content: center;
+ align-items: center;
+ }
+
+ &--pill {
+ display: flex;
+ }
}
.header-link,
@@ -182,10 +340,21 @@
}
}
+// ==========================================================================
+// Misc
+// ==========================================================================
.info {
margin-inline-start: awsui.$space-xs;
}
+.item-icon {
+ display: inline-flex;
+ flex-shrink: 0;
+ &:not(.item-icon-collapsed) {
+ margin-inline-end: $item-icon-text-gap;
+ }
+}
+
.external-icon {
margin-inline-start: awsui.$space-xxs;
}
@@ -193,18 +362,19 @@
.divider {
border-block: none;
border-inline: none;
-}
-
-.divider-default {
- margin-block: awsui.$space-scaled-2x-xl;
- margin-inline: calc(-1 * #{awsui.$space-panel-divider-margin-horizontal});
- border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-divider-default;
-}
-
-.divider-header {
- margin-block: 0;
- border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-panel-header;
- .with-toolbar > & {
- border-color: transparent;
+ &-default {
+ margin-block: awsui.$space-scaled-2x-xl;
+ margin-inline: calc(-1 * #{awsui.$space-panel-divider-margin-horizontal});
+ border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-divider-default;
+ &.divider-collapsed {
+ margin-block: awsui.$space-scaled-2x-m;
+ }
+ }
+ &-header {
+ margin-block: 0;
+ border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-panel-header;
+ .with-toolbar > & {
+ border-color: transparent;
+ }
}
}
diff --git a/src/side-navigation/test-classes/styles.scss b/src/side-navigation/test-classes/styles.scss
index 63ac853f7b..7f06e59e0e 100644
--- a/src/side-navigation/test-classes/styles.scss
+++ b/src/side-navigation/test-classes/styles.scss
@@ -5,3 +5,7 @@
.info {
/* used in test-utils */
}
+
+.item-icon {
+ /* used in test-utils */
+}
diff --git a/style-dictionary/utils/token-names.ts b/style-dictionary/utils/token-names.ts
index 35b14f9a09..3fd56cf707 100644
--- a/style-dictionary/utils/token-names.ts
+++ b/style-dictionary/utils/token-names.ts
@@ -1057,7 +1057,8 @@ export type SizesTokenName =
| 'sizeIconNormal'
| 'sizeTableSelectionHorizontal'
| 'sizeVerticalInput'
- | 'sizeVerticalPanelIconOffset';
+ | 'sizeVerticalPanelIconOffset'
+ | 'sizeSideNavigationItemHeight';
export type SpacingTokenName =
| 'spaceAlertActionLeft'
| 'spaceAlertHorizontal'
@@ -1130,6 +1131,8 @@ export type SpacingTokenName =
| 'spaceTableHorizontal'
| 'spaceTileGutter'
| 'spaceTreeViewIndentation'
+ | 'spaceSideNavigationItemGap'
+ | 'spaceSideNavigationItemCollapsedGap'
| 'spaceL'
| 'spaceM'
| 'spaceNone'
diff --git a/style-dictionary/visual-refresh/metadata/colors.ts b/style-dictionary/visual-refresh/metadata/colors.ts
index e583f0039e..c2cd227abd 100644
--- a/style-dictionary/visual-refresh/metadata/colors.ts
+++ b/style-dictionary/visual-refresh/metadata/colors.ts
@@ -168,6 +168,8 @@ const metadata: StyleDictionary.MetadataIndex = {
colorBackgroundLayoutPanelContent: {
description:
'The background color of app layout panel content area. For example: The side navigation and tools panel content background color.',
+ public: true,
+ themeable: true,
},
colorBackgroundLayoutToolbar: {
description:
diff --git a/style-dictionary/visual-refresh/metadata/sizes.ts b/style-dictionary/visual-refresh/metadata/sizes.ts
index 5231424e5b..f4e41b8d7e 100644
--- a/style-dictionary/visual-refresh/metadata/sizes.ts
+++ b/style-dictionary/visual-refresh/metadata/sizes.ts
@@ -9,6 +9,11 @@ const metadata: StyleDictionary.MetadataIndex = {
public: true,
themeable: true,
},
+ sizeSideNavigationItemHeight: {
+ description: 'The minimum height of side navigation items.',
+ public: true,
+ themeable: true,
+ },
};
export default metadata;
diff --git a/style-dictionary/visual-refresh/metadata/spacing.ts b/style-dictionary/visual-refresh/metadata/spacing.ts
index 8b68212714..02a8569c40 100644
--- a/style-dictionary/visual-refresh/metadata/spacing.ts
+++ b/style-dictionary/visual-refresh/metadata/spacing.ts
@@ -174,6 +174,16 @@ const metadata: StyleDictionary.MetadataIndex = {
public: true,
themeable: false,
},
+ spaceSideNavigationItemGap: {
+ description: 'The vertical gap between side navigation items.',
+ public: true,
+ themeable: true,
+ },
+ spaceSideNavigationItemCollapsedGap: {
+ description: 'The vertical gap between side navigation items in collapsed state.',
+ public: true,
+ themeable: true,
+ },
spaceButtonHorizontal: {
description: 'The horizontal padding inside buttons.',
public: true,
diff --git a/style-dictionary/visual-refresh/sizes.ts b/style-dictionary/visual-refresh/sizes.ts
index d80230db33..ccf991f19b 100644
--- a/style-dictionary/visual-refresh/sizes.ts
+++ b/style-dictionary/visual-refresh/sizes.ts
@@ -13,6 +13,7 @@ const tokens: StyleDictionary.SizesDictionary = {
sizeTableSelectionHorizontal: '40px',
sizeVerticalInput: { comfortable: '32px', compact: '28px' },
sizeVerticalPanelIconOffset: { comfortable: '15px', compact: '13px' },
+ sizeSideNavigationItemHeight: '28px',
};
const expandedTokens: StyleDictionary.ExpandedDensityScopeDictionary = expandDensityDictionary(tokens);
diff --git a/style-dictionary/visual-refresh/spacing.ts b/style-dictionary/visual-refresh/spacing.ts
index 2dae31ea8b..0e37387a4c 100644
--- a/style-dictionary/visual-refresh/spacing.ts
+++ b/style-dictionary/visual-refresh/spacing.ts
@@ -52,7 +52,7 @@ const tokens: StyleDictionary.SpacingDictionary = {
spaceLayoutTogglePadding: '{spaceStaticS}',
spaceModalContentBottom: '{spaceScaled2xM}',
spaceModalHorizontal: '{spaceContainerHorizontal}',
- spacePanelContentBottom: '{spaceScaledXxxl}',
+ spacePanelContentBottom: '{spaceScaledL}',
spacePanelContentTop: '{spaceScaledL}',
spacePanelDividerMarginHorizontal: '{spaceXs}',
spacePanelHeaderVertical: '{spaceScaledL}',
@@ -74,6 +74,8 @@ const tokens: StyleDictionary.SpacingDictionary = {
spaceTableHeaderToolsFullPageBottom: '4px',
spaceTableHorizontal: '{spaceContainerHorizontal}',
spaceTreeViewIndentation: '{spaceXl}',
+ spaceSideNavigationItemGap: '{spaceScaledXxs}',
+ spaceSideNavigationItemCollapsedGap: '{spaceXs}',
spaceTileGutter: { comfortable: '{spaceXl}', compact: '{spaceM}' },
spaceActionCardHorizontalDefault: '{spaceCardHorizontalDefault}',
spaceActionCardHorizontalEmbedded: '{spaceCardHorizontalEmbedded}',
From ada39f38b61bdbcf07ad8936a1ed848d94500310 Mon Sep 17 00:00:00 2001
From: Jessica Kuelz <15003460+jkuelz@users.noreply.github.com>
Date: Tue, 9 Jun 2026 00:19:22 -0700
Subject: [PATCH 2/3] feat: Add minimal demo pages for SideNavigation API
review
- collapsed.page.tsx: basic collapsed/expanded toggle with focus management
- collapsed-toggle-placement.page.tsx: toggle in 4 positions (top/bottom/above-items/below-items)
- collapsed-panel-layout.page.tsx: integration with PanelLayout for resizable panel
- collapsed-permutations.page.tsx: all item types showing collapsed behavior rules
---
.../collapsed-panel-layout.page.tsx | 101 +++++++++++++
.../collapsed-permutations.page.tsx | 138 ++++++++++++++++++
.../collapsed-toggle-placement.page.tsx | 121 +++++++++++++++
pages/side-navigation/collapsed.page.tsx | 100 +++++++++++++
4 files changed, 460 insertions(+)
create mode 100644 pages/side-navigation/collapsed-panel-layout.page.tsx
create mode 100644 pages/side-navigation/collapsed-permutations.page.tsx
create mode 100644 pages/side-navigation/collapsed-toggle-placement.page.tsx
create mode 100644 pages/side-navigation/collapsed.page.tsx
diff --git a/pages/side-navigation/collapsed-panel-layout.page.tsx b/pages/side-navigation/collapsed-panel-layout.page.tsx
new file mode 100644
index 0000000000..16f5b43fcd
--- /dev/null
+++ b/pages/side-navigation/collapsed-panel-layout.page.tsx
@@ -0,0 +1,101 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import React, { useState } from 'react';
+
+import { Box, Button, Icon, PanelLayout, SpaceBetween } from '~components';
+import SideNavigation, { SideNavigationProps } from '~components/side-navigation';
+
+const items: SideNavigationProps.Item[] = [
+ { type: 'link', text: 'Home', href: '#/home', icon: },
+ { type: 'link', text: 'Projects', href: '#/projects', icon: },
+ { type: 'link', text: 'Settings', href: '#/settings', icon: },
+ {
+ type: 'section',
+ text: 'Account',
+ items: [
+ { type: 'link', text: 'Profile', href: '#/profile', icon: },
+ { type: 'link', text: 'Billing', href: '#/billing', icon: },
+ ],
+ },
+];
+
+const COLLAPSED_SIZE = 52;
+const EXPANDED_SIZE = 220;
+const MAX_SIZE = 400;
+const COLLAPSE_THRESHOLD = 140;
+const SNAP_BUFFER = 30;
+
+export default function SideNavigationWithPanelLayoutPage() {
+ const [activeHref, setActiveHref] = useState('#/home');
+ const [panelSize, setPanelSize] = useState(EXPANDED_SIZE);
+
+ const collapsed = panelSize < COLLAPSE_THRESHOLD;
+
+ function handleResize({ detail }: { detail: { panelSize: number } }) {
+ const size = detail.panelSize;
+ if (collapsed && size > COLLAPSED_SIZE + SNAP_BUFFER) {
+ setPanelSize(Math.max(size, EXPANDED_SIZE));
+ } else if (!collapsed && size < COLLAPSE_THRESHOLD) {
+ setPanelSize(COLLAPSED_SIZE);
+ } else {
+ setPanelSize(size);
+ }
+ }
+
+ function toggleCollapse() {
+ setPanelSize(prev => (prev < COLLAPSE_THRESHOLD ? EXPANDED_SIZE : COLLAPSED_SIZE));
+ }
+
+ const nav = (
+
+
+
+
+
{
+ e.preventDefault();
+ setActiveHref(e.detail.href);
+ }}
+ />
+
+ );
+
+ const content = (
+
+
+ SideNavigation with PanelLayout
+
+ Panel size: {panelSize}px — Collapsed: {String(collapsed)}
+
+
+ Drag the resize handle to resize the panel. Dragging below {COLLAPSE_THRESHOLD}px snaps to collapsed state.
+
+
+
+ );
+
+ return (
+
+ );
+}
diff --git a/pages/side-navigation/collapsed-permutations.page.tsx b/pages/side-navigation/collapsed-permutations.page.tsx
new file mode 100644
index 0000000000..7c886f6555
--- /dev/null
+++ b/pages/side-navigation/collapsed-permutations.page.tsx
@@ -0,0 +1,138 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import React, { useState } from 'react';
+
+import { Box, Button, Icon, SpaceBetween, Toggle } from '~components';
+import SideNavigation, { SideNavigationProps } from '~components/side-navigation';
+
+// Items covering all types to verify collapsed behavior
+const allTypesItems: SideNavigationProps.Item[] = [
+ // Links with and without icons
+ { type: 'link', text: 'Link with icon', href: '#/icon', icon: },
+ { type: 'link', text: 'Link without icon (hidden collapsed)', href: '#/no-icon' },
+ { type: 'divider' },
+ // Link group
+ {
+ type: 'link-group',
+ text: 'Link group (with icon)',
+ href: '#/group',
+ icon: ,
+ items: [
+ { type: 'link', text: 'Child 1', href: '#/child1' },
+ { type: 'link', text: 'Child 2', href: '#/child2' },
+ ],
+ },
+ {
+ type: 'link-group',
+ text: 'Link group (no icon, hidden)',
+ href: '#/group-no-icon',
+ items: [{ type: 'link', text: 'Child', href: '#/child' }],
+ },
+ { type: 'divider' },
+ // Expandable link group
+ {
+ type: 'expandable-link-group',
+ text: 'Expandable group (with icon)',
+ href: '#/expandable',
+ icon: ,
+ items: [
+ { type: 'link', text: 'Sub 1', href: '#/sub1' },
+ { type: 'link', text: 'Sub 2', href: '#/sub2' },
+ ],
+ },
+ {
+ type: 'expandable-link-group',
+ text: 'Expandable group (no icon, hidden)',
+ href: '#/expandable-no-icon',
+ items: [{ type: 'link', text: 'Sub', href: '#/sub' }],
+ },
+ { type: 'divider' },
+ // Section
+ {
+ type: 'section',
+ text: 'Section (header hidden in collapsed)',
+ items: [
+ { type: 'link', text: 'Section child with icon', href: '#/s1', icon: },
+ { type: 'link', text: 'Section child without icon (hidden)', href: '#/s2' },
+ { type: 'link', text: 'Another with icon', href: '#/s3', icon: },
+ ],
+ },
+ { type: 'divider' },
+ // Section group
+ {
+ type: 'section-group',
+ title: 'Section group',
+ items: [
+ {
+ type: 'section',
+ text: 'Nested section',
+ items: [{ type: 'link', text: 'Nested item with icon', href: '#/n1', icon: }],
+ },
+ ],
+ },
+];
+
+export default function SideNavigationCollapsedPermutationsPage() {
+ const [activeHref, setActiveHref] = useState('#/icon');
+ const [collapsed, setCollapsed] = useState(false);
+ const [highlighted, setHighlighted] = useState(true);
+
+ return (
+
+
+
+ setCollapsed(c => !c)}
+ ariaLabel={collapsed ? 'Expand' : 'Collapse'}
+ />
+
+
{
+ e.preventDefault();
+ setActiveHref(e.detail.href);
+ }}
+ />
+
+
+
+
+ Collapsed state — all item types
+
+ setCollapsed(detail.checked)}>
+ Collapsed
+
+ setHighlighted(detail.checked)}>
+ Highlighted variant
+
+
+ In collapsed mode:
+ ✓ Links with icons → shown as icon only
+ ✗ Links without icons → hidden
+ ✓ Link groups with icons → shown as icon only
+ ✗ Link groups without icons → hidden
+ ✓ Expandable groups with icons → shown as icon, non-expandable
+ ✗ Expandable groups without icons → hidden
+ ✓ Sections → header hidden, children with icons shown as flat list
+ ✓ Dividers → deduplicated
+
+
+
+ );
+}
diff --git a/pages/side-navigation/collapsed-toggle-placement.page.tsx b/pages/side-navigation/collapsed-toggle-placement.page.tsx
new file mode 100644
index 0000000000..89de1ad0b8
--- /dev/null
+++ b/pages/side-navigation/collapsed-toggle-placement.page.tsx
@@ -0,0 +1,121 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import React, { useContext, useRef, useState } from 'react';
+
+import { Box, Button, Icon, Select, SpaceBetween } from '~components';
+import SideNavigation, { SideNavigationProps } from '~components/side-navigation';
+
+import AppContext, { AppContextType } from '../app/app-context';
+
+const items: SideNavigationProps.Item[] = [
+ { type: 'link', text: 'Dashboard', href: '#/dashboard', icon: },
+ { type: 'link', text: 'Workspaces', href: '#/workspaces', icon: },
+ { type: 'link', text: 'Team', href: '#/team', icon: },
+ { type: 'link', text: 'Billing', href: '#/billing', icon: },
+ { type: 'link', text: 'Item without icon — hidden when collapsed', href: '#/no-icon' },
+];
+
+type TogglePosition = 'top' | 'bottom' | 'above-items' | 'below-items';
+type PageContext = React.Context>;
+
+const COLLAPSED_WIDTH = 52;
+const EXPANDED_WIDTH = 220;
+
+export default function SideNavigationTogglePlacementPage() {
+ const {
+ urlParams: { togglePosition = 'top' },
+ setUrlParams,
+ } = useContext(AppContext as PageContext);
+
+ const [activeHref, setActiveHref] = useState('#/dashboard');
+ const [collapsed, setCollapsed] = useState(false);
+ const [panelWidth, setPanelWidth] = useState(EXPANDED_WIDTH);
+ const navRef = useRef(null);
+ const toggleRef = useRef(null);
+
+ function handleToggle() {
+ const willCollapse = !collapsed;
+ if (willCollapse) {
+ const focused = document.activeElement;
+ if (navRef.current?.contains(focused)) {
+ toggleRef.current?.focus();
+ }
+ }
+ setCollapsed(willCollapse);
+ setPanelWidth(willCollapse ? COLLAPSED_WIDTH : EXPANDED_WIDTH);
+ }
+
+ const toggleButton = (
+
+ );
+
+ const toggleWrapper = (pos: TogglePosition) => (
+
+ {toggleButton}
+
+ );
+
+ return (
+
+
+ {(togglePosition === 'top' || togglePosition === 'above-items') && toggleWrapper(togglePosition)}
+
+ {
+ e.preventDefault();
+ setActiveHref(e.detail.href);
+ }}
+ />
+
+ {(togglePosition === 'bottom' || togglePosition === 'below-items') && toggleWrapper(togglePosition)}
+
+
+
+
+ Toggle placement
+ setUrlParams({ togglePosition: detail.selectedOption.value as TogglePosition })}
+ />
+
+ The toggle button can be placed anywhere in the nav panel. Use URL params to switch positions.
+
+
+
+
+ );
+}
diff --git a/pages/side-navigation/collapsed.page.tsx b/pages/side-navigation/collapsed.page.tsx
new file mode 100644
index 0000000000..43ee190ca6
--- /dev/null
+++ b/pages/side-navigation/collapsed.page.tsx
@@ -0,0 +1,100 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import React, { useRef, useState } from 'react';
+
+import { Box, Button, Icon, SpaceBetween } from '~components';
+import SideNavigation, { SideNavigationProps } from '~components/side-navigation';
+
+const items: SideNavigationProps.Item[] = [
+ { type: 'link', text: 'Overview', href: '#/overview', icon: },
+ { type: 'link', text: 'Projects', href: '#/projects', icon: },
+ { type: 'link', text: 'Settings', href: '#/settings', icon: },
+ { type: 'divider' },
+ {
+ type: 'section',
+ text: 'Resources',
+ items: [
+ { type: 'link', text: 'Compute', href: '#/compute', icon: },
+ { type: 'link', text: 'Storage', href: '#/storage', icon: },
+ { type: 'link', text: 'Networking', href: '#/networking', icon: },
+ ],
+ },
+ { type: 'divider' },
+ { type: 'link', text: 'Documentation', href: '#/docs', icon: , external: true },
+];
+
+const COLLAPSED_WIDTH = 52;
+const EXPANDED_WIDTH = 220;
+
+export default function SideNavigationCollapsedPage() {
+ const [activeHref, setActiveHref] = useState('#/overview');
+ const [collapsed, setCollapsed] = useState(false);
+ const [panelWidth, setPanelWidth] = useState(EXPANDED_WIDTH);
+ const navRef = useRef(null);
+ const toggleRef = useRef(null);
+
+ function handleToggle() {
+ const willCollapse = !collapsed;
+ // Focus management: move focus to toggle if focused item will be hidden
+ if (willCollapse) {
+ const focused = document.activeElement;
+ if (navRef.current?.contains(focused)) {
+ toggleRef.current?.focus();
+ }
+ }
+ setCollapsed(willCollapse);
+ setPanelWidth(willCollapse ? COLLAPSED_WIDTH : EXPANDED_WIDTH);
+ }
+
+ return (
+
+ {/* Nav panel */}
+
+ {/* Toggle button at top */}
+
+
+
+ {
+ e.preventDefault();
+ setActiveHref(e.detail.href);
+ }}
+ />
+
+
+ {/* Main content */}
+
+
+ Collapsed state demo
+ Active: {activeHref}
+
+ Toggle the navigation panel using the button. Items without icons are hidden in collapsed mode. Sections
+ show their icon-bearing children as a flat list.
+
+
+
+
+ );
+}
From ab880714499026f89d423daab6c36efb615e5916 Mon Sep 17 00:00:00 2001
From: Jessica Kuelz <15003460+jkuelz@users.noreply.github.com>
Date: Tue, 9 Jun 2026 02:13:32 -0700
Subject: [PATCH 3/3] feat: Fix collapsed state behavior and padding for
highlighted variant
- Flatten section/section-group children with icons in collapsed mode
- Apply list-variant-root--collapsed to zero padding when collapsed
- Apply list-variant-root--symmetric for highlighted variant in expanded mode
- Improve demo pages: scroll, toggle alignment, transition timing
---
.../collapsed-panel-layout.page.tsx | 14 ++-
.../collapsed-permutations.page.tsx | 11 ++-
.../collapsed-toggle-placement.page.tsx | 98 +++++++++++--------
pages/side-navigation/collapsed.page.tsx | 24 ++---
src/side-navigation/parts.tsx | 40 +++++++-
src/side-navigation/styles.scss | 17 ++--
6 files changed, 134 insertions(+), 70 deletions(-)
diff --git a/pages/side-navigation/collapsed-panel-layout.page.tsx b/pages/side-navigation/collapsed-panel-layout.page.tsx
index 16f5b43fcd..bc51ba8aef 100644
--- a/pages/side-navigation/collapsed-panel-layout.page.tsx
+++ b/pages/side-navigation/collapsed-panel-layout.page.tsx
@@ -20,10 +20,10 @@ const items: SideNavigationProps.Item[] = [
},
];
-const COLLAPSED_SIZE = 52;
+const COLLAPSED_SIZE = 65;
const EXPANDED_SIZE = 220;
const MAX_SIZE = 400;
-const COLLAPSE_THRESHOLD = 140;
+const COLLAPSE_THRESHOLD = 155;
const SNAP_BUFFER = 30;
export default function SideNavigationWithPanelLayoutPage() {
@@ -48,8 +48,14 @@ export default function SideNavigationWithPanelLayoutPage() {
}
const nav = (
-
-
+
+
-
+
>;
+type TogglePosition = 'above' | 'below';
+type ToggleAlign = 'start' | 'end';
+type PageContext = React.Context>;
const COLLAPSED_WIDTH = 52;
const EXPANDED_WIDTH = 220;
export default function SideNavigationTogglePlacementPage() {
const {
- urlParams: { togglePosition = 'top' },
+ urlParams: { togglePosition = 'above', toggleAlign = 'start' },
setUrlParams,
} = useContext(AppContext as PageContext);
@@ -47,24 +48,21 @@ export default function SideNavigationTogglePlacementPage() {
}
const toggleButton = (
-
- );
-
- const toggleWrapper = (pos: TogglePosition) => (
- {toggleButton}
+
);
@@ -75,44 +73,60 @@ export default function SideNavigationTogglePlacementPage() {
style={{
inlineSize: `${panelWidth}px`,
flexShrink: 0,
- transition: 'inline-size 250ms cubic-bezier(0, 0, 0, 1)',
+ transition: 'inline-size 400ms cubic-bezier(0, 0, 0, 1)',
overflow: 'hidden',
borderInlineEnd: '1px solid #e9ebed',
display: 'flex',
flexDirection: 'column',
}}
>
- {(togglePosition === 'top' || togglePosition === 'above-items') && toggleWrapper(togglePosition)}
-
- {
- e.preventDefault();
- setActiveHref(e.detail.href);
- }}
- />
-
- {(togglePosition === 'bottom' || togglePosition === 'below-items') && toggleWrapper(togglePosition)}
+ {togglePosition === 'above' && toggleButton}
+ {
+ e.preventDefault();
+ setActiveHref(e.detail.href);
+ }}
+ />
+ {togglePosition === 'below' && toggleButton}
Toggle placement
- setUrlParams({ togglePosition: detail.selectedOption.value as TogglePosition })}
- />
+
+
+
+ Position
+
+ setUrlParams({ togglePosition: detail.value as TogglePosition })}
+ items={[
+ { value: 'above', label: 'Above items' },
+ { value: 'below', label: 'Below items' },
+ ]}
+ />
+
+
+
+ Alignment
+
+ setUrlParams({ toggleAlign: detail.value as ToggleAlign })}
+ items={[
+ { value: 'start', label: 'Start' },
+ { value: 'end', label: 'End' },
+ ]}
+ />
+
+
- The toggle button can be placed anywhere in the nav panel. Use URL params to switch positions.
+ The toggle button can be placed above or below the items with start/end alignment.
diff --git a/pages/side-navigation/collapsed.page.tsx b/pages/side-navigation/collapsed.page.tsx
index 43ee190ca6..9c8e0cf91d 100644
--- a/pages/side-navigation/collapsed.page.tsx
+++ b/pages/side-navigation/collapsed.page.tsx
@@ -55,7 +55,7 @@ export default function SideNavigationCollapsedPage() {
style={{
inlineSize: `${panelWidth}px`,
flexShrink: 0,
- transition: 'inline-size 250ms cubic-bezier(0, 0, 0, 1)',
+ transition: 'inline-size 400ms cubic-bezier(0, 0, 0, 1)',
overflow: 'hidden',
borderInlineEnd: '1px solid #e9ebed',
display: 'flex',
@@ -72,16 +72,18 @@ export default function SideNavigationCollapsedPage() {
ariaLabel={collapsed ? 'Expand navigation' : 'Collapse navigation'}
/>
-
{
- e.preventDefault();
- setActiveHref(e.detail.href);
- }}
- />
+
+ {
+ e.preventDefault();
+ setActiveHref(e.detail.href);
+ }}
+ />
+
{/* Main content */}
diff --git a/src/side-navigation/parts.tsx b/src/side-navigation/parts.tsx
index 0151d42a2a..0d6f14f89c 100644
--- a/src/side-navigation/parts.tsx
+++ b/src/side-navigation/parts.tsx
@@ -142,6 +142,38 @@ export function NavigationItemsList({
// In collapsed mode, only selectable items are visible (links, link-groups, ELGs).
// Sections and section-groups are non-selectable containers — skip them.
if (collapsed && (item.type === 'section' || item.type === 'section-group')) {
+ // Flatten icon-bearing children into the current list
+ const childItems: ReadonlyArray =
+ item.type === 'section'
+ ? (item as SideNavigationProps.Section).items
+ : (item as SideNavigationProps.SectionGroup).items.flatMap(child =>
+ child.type === 'section' ? (child as SideNavigationProps.Section).items : [child]
+ );
+ childItems.forEach((child, childIndex) => {
+ const childLink = child as
+ | SideNavigationProps.Link
+ | SideNavigationProps.LinkGroup
+ | SideNavigationProps.ExpandableLinkGroup;
+ if (!childLink.icon) {
+ return;
+ }
+ const childPosition = `${position ? `${position},` : ''}${itemid},${childIndex + 1}`;
+ lists[currentListIndex].items?.push({
+ element: (
+
+
+
+ ),
+ });
+ });
return;
}
if (collapsed && item.type !== 'divider' && !item.icon) {
@@ -307,8 +339,10 @@ export function NavigationItemsList({
key={`hr-${index}`}
className={clsx(styles.list, styles[`list-variant-${variant}`], {
[styles['list-variant-root--first']]: list.listVariant === 'root' && index === 0,
+ [styles['list-variant-root--collapsed']]: list.listVariant === 'root' && collapsed,
[styles['list-variant-root--symmetric']]:
- list.listVariant === 'root' && (collapsed || expandIconPosition === 'end'),
+ list.listVariant === 'root' &&
+ (collapsed || expandIconPosition === 'end' || highlightVariant === 'highlighted'),
})}
>
{list.element}
@@ -320,8 +354,10 @@ export function NavigationItemsList({
key={`list-${index}`}
className={clsx(styles.list, styles[`list-variant-${list.listVariant}`], {
[styles['list-variant-root--first']]: list.listVariant === 'root' && index === 0,
+ [styles['list-variant-root--collapsed']]: list.listVariant === 'root' && collapsed,
[styles['list-variant-root--symmetric']]:
- list.listVariant === 'root' && (collapsed || expandIconPosition === 'end'),
+ list.listVariant === 'root' &&
+ (collapsed || expandIconPosition === 'end' || highlightVariant === 'highlighted'),
[styles[`expand-icon-end`]]: expandIconPosition === 'end',
})}
>
diff --git a/src/side-navigation/styles.scss b/src/side-navigation/styles.scss
index e347cccc2a..730b239f41 100644
--- a/src/side-navigation/styles.scss
+++ b/src/side-navigation/styles.scss
@@ -203,10 +203,11 @@ $expandable-icon-negative-margin: 19px;
& > div {
padding-block: 0;
padding-inline: 0;
- &:first-child {
+ /* stylelint-disable-next-line selector-max-type */
+ > :first-child {
display: grid;
- align-items: center;
- min-block-size: $item-height;
+ align-items: flex-start;
+ padding-block: calc((#{$item-height} - #{awsui.$line-height-body-m}) / 2);
}
}
@@ -228,7 +229,7 @@ $expandable-icon-negative-margin: 19px;
}
.expandable-link-group-active > div:first-child {
- background-color: color-mix(in srgb, #{awsui.$color-item-selected} 10%, transparent);
+ background-color: color-mix(in srgb, #{awsui.$color-item-selected} 8%, transparent);
}
.section {
@@ -257,7 +258,6 @@ $expandable-icon-negative-margin: 19px;
.section-group-title {
display: flex;
- min-block-size: $item-height;
align-items: center;
margin-block: 0;
padding-block: awsui.$space-scaled-xxs;
@@ -280,11 +280,11 @@ $expandable-icon-negative-margin: 19px;
@include styles.font-body-m;
color: awsui.$color-text-body-secondary;
display: inline-flex;
- min-block-size: $item-height;
+ padding-block: calc((#{$item-height} - #{awsui.$line-height-body-m}) / 2);
min-inline-size: $item-height;
padding-inline: $item-padding-inline;
margin-inline: calc(-1 * #{$item-padding-inline});
- align-items: center;
+ align-items: flex-start;
@include item-radius;
font-weight: styles.$font-weight-normal;
-webkit-font-smoothing: auto;
@@ -300,13 +300,12 @@ $expandable-icon-negative-margin: 19px;
@include styles.font-smoothing;
color: awsui.$color-text-accent;
&.link--pill {
- background-color: color-mix(in srgb, #{awsui.$color-background-item-selected} 50%, transparent);
+ background-color: color-mix(in srgb, #{awsui.$color-item-selected} 8%, transparent);
color: awsui.$color-item-selected;
}
}
&--collapsed {
- padding-block: 0;
padding-inline: 0;
margin-inline: 0;
justify-content: center;