diff --git a/src/components/layout/Layout.mdx b/src/components/layout/Layout.mdx
new file mode 100644
index 0000000000..57244fe1ef
--- /dev/null
+++ b/src/components/layout/Layout.mdx
@@ -0,0 +1,27 @@
+import { Subtitle, ArgsTable } from '@storybook/addon-docs/blocks';
+import Layout from './index';
+
+# Layout 布局
+
+协助进行页面级整体布局。
+
+## 参数说明
+
+
+
+## 代码演示
+
+### 样式 - 无侧导航页面
+[Example](/?path=/story/basic-components-layout--default)
+
+### 样式 - 收缩侧导航页面
+
+[Example](/?path=/story/basic-components-layout--sider)
+
+### 样式 - 悬浮式展开侧导航
+
+[Example](/?path=/story/basic-components-layout--suspend)
+
+### 样式 - 嵌入式展开侧导航
+
+[Example](/?path=/story/basic-components-layout--embed)
diff --git a/src/components/layout/Layout.stories.tsx b/src/components/layout/Layout.stories.tsx
new file mode 100644
index 0000000000..3944da8cb7
--- /dev/null
+++ b/src/components/layout/Layout.stories.tsx
@@ -0,0 +1,76 @@
+import React, { useState } from 'react';
+import { Story, Meta } from '@storybook/react/types-6-0';
+import Docs from './Layout.mdx';
+import { LayoutProps } from './interfaces';
+import Layout from './index';
+import Button from '../button';
+import './style';
+
+export default {
+ title: 'Basic Components/Layout',
+ component: Layout,
+ parameters: {
+ docs: {
+ page: Docs,
+ },
+ },
+} as Meta;
+
+const Template: Story = (args) => ;
+
+export const Default = Template.bind({});
+
+Default.args = {
+ children: (
+ <>
+
+
+ >
+ ),
+};
+
+export const Sider = Template.bind({});
+
+Sider.args = {
+ children: (
+ <>
+
+
+
+
+
+ >
+ ),
+};
+
+// eslint-disable-next-line react/jsx-props-no-spreading
+const DemoListTemplate: Story = (args) => ;
+
+export const Suspend = DemoListTemplate.bind({});
+
+const SuspendDemo = ({ suspend }: { suspend?: 'left' | 'right' }) => {
+ const [collapsed, setCollapsed] = useState(false);
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
+
+Suspend.args = {
+ children: ,
+};
+
+export const Embed = DemoListTemplate.bind({});
+
+Embed.args = {
+ children: ,
+};
diff --git a/src/components/layout/__tests__/Layout.test.tsx b/src/components/layout/__tests__/Layout.test.tsx
new file mode 100644
index 0000000000..4318a66641
--- /dev/null
+++ b/src/components/layout/__tests__/Layout.test.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import { renderHook, act } from '@testing-library/react-hooks';
+import Layout from '../layout';
+import useSiders from '../useSiders';
+
+describe('Testing Layout', () => {
+ it('should be stable', () => {
+ const { asFragment } = render(
+
+
+
+
+
+
+
+ );
+ expect(asFragment()).toMatchSnapshot();
+ });
+
+ test('Content maxWidth', () => {
+ const { getByText } = render(
+
+ content
+
+ );
+ const contentElement = getByText('content');
+ expect(contentElement.style.getPropertyValue('--layout-content-maxWidth')).toBe('100%');
+ expect(contentElement.style.getPropertyValue('--layout-content-grow')).toBe('1');
+ expect(contentElement.style.getPropertyValue('margin')).toBe('0px 20px');
+ });
+
+ test('Content margin', () => {
+ const { getByText } = render(
+
+
+ content
+
+
+ );
+ expect(getByText('content').style.getPropertyValue('margin')).toBe('0px 10px');
+ });
+
+ test('useSiders', () => {
+ const { result } = renderHook(() => useSiders());
+ const _updateSiders = result.current[3];
+ act(() => {
+ _updateSiders({ id: '1', width: 200, collapsedWidth: 80, suspendedPosition: 'left' });
+ _updateSiders({ id: '2', width: 300, collapsedWidth: 100, suspendedPosition: 'right' });
+ _updateSiders({ id: '3', width: 100, collapsedWidth: 50 });
+ });
+ const [siders, sidersWidth, removeSider, updateSiders, margin] = result.current;
+ expect(siders.length).toBe(3);
+ expect(sidersWidth).toBe(600);
+ expect(margin).toStrictEqual([80, 100]);
+ act(() => {
+ removeSider('1');
+ });
+ expect(result.current[0].length).toBe(2);
+ expect(result.current[1]).toBe(400);
+ expect(result.current[4]).toStrictEqual([0, 100]);
+ act(() => {
+ updateSiders({ id: '3', width: 100, collapsedWidth: 50, suspendedPosition: 'left' });
+ });
+ expect(result.current[4]).toStrictEqual([50, 100]);
+ act(() => {
+ removeSider('2');
+ });
+ expect(result.current[4]).toStrictEqual([50, 0]);
+ });
+});
diff --git a/src/components/layout/__tests__/__snapshots__/Layout.test.tsx.snap b/src/components/layout/__tests__/__snapshots__/Layout.test.tsx.snap
new file mode 100644
index 0000000000..fddb25e466
--- /dev/null
+++ b/src/components/layout/__tests__/__snapshots__/Layout.test.tsx.snap
@@ -0,0 +1,27 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Testing Layout should be stable 1`] = `
+
+
+
+`;
diff --git a/src/components/layout/content.tsx b/src/components/layout/content.tsx
new file mode 100644
index 0000000000..f4081bd936
--- /dev/null
+++ b/src/components/layout/content.tsx
@@ -0,0 +1,41 @@
+import React, { useContext, useEffect, useMemo } from 'react';
+import classNames from 'classnames';
+import { isNumber } from 'lodash';
+import { LayoutContentProps } from './interfaces';
+import usePrefixCls from '../../utils/hooks/use-prefix-cls';
+import { LayoutContext } from './layout';
+
+const Content = ({
+ prefixCls: customizePrefixCls,
+ className,
+ style,
+ children,
+ maxWidth = 1200,
+ margin = 20
+}: LayoutContentProps) => {
+
+ const { layoutState, setContentState } = useContext(LayoutContext);
+ useEffect(() => {
+ setContentState({ maxWidth: isNumber(maxWidth) ? maxWidth : 0, margin });
+ }, [margin, maxWidth, setContentState]);
+
+ const prefixCls = usePrefixCls('layout-content', customizePrefixCls);
+
+ const mergedStyle: React.CSSProperties = useMemo(() => ({
+ '--layout-content-maxWidth': maxWidth === 'auto' ? '100%' : `${maxWidth}px`,
+ '--layout-content-grow': maxWidth === 'auto' ? 1 : 0,
+ ...((maxWidth === 'auto' || !layoutState.wide) ? { margin: `0 ${margin}px`} : {}),
+ ...style
+ }), [maxWidth, layoutState.wide, margin, style]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default Content;
\ No newline at end of file
diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx
new file mode 100644
index 0000000000..0145259144
--- /dev/null
+++ b/src/components/layout/header.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import classNames from 'classnames';
+import { LayoutProps } from './interfaces';
+import usePrefixCls from '../../utils/hooks/use-prefix-cls';
+
+const Header = ({
+ prefixCls: customizePrefixCls,
+ className,
+ style,
+ children
+}: LayoutProps) => {
+
+ const prefixCls = usePrefixCls('layout-header', customizePrefixCls);
+ return (
+
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts
new file mode 100644
index 0000000000..07ec302e5c
--- /dev/null
+++ b/src/components/layout/index.ts
@@ -0,0 +1,4 @@
+import Layout from './layout';
+
+export { LayoutProps } from './interfaces';
+export default Layout;
diff --git a/src/components/layout/interfaces.ts b/src/components/layout/interfaces.ts
new file mode 100644
index 0000000000..4a46329af5
--- /dev/null
+++ b/src/components/layout/interfaces.ts
@@ -0,0 +1,69 @@
+export interface LayoutProps {
+ className?: string;
+ prefixCls?: string;
+ children?: React.ReactNode | React.ReactNode[];
+ style?: React.CSSProperties;
+}
+
+export interface LayoutContentProps extends LayoutProps {
+ /**
+ Content 区域的最大宽度
+ */
+ maxWidth?: number | 'auto';
+ /*
+ Content 区域的外边距
+ */
+ margin?: number;
+}
+
+export interface LayoutSiderProps extends LayoutProps {
+ /*
+ 收缩状态的宽度
+ */
+ collapsedWidth?: number;
+ /*
+ 展开状态的宽度
+ */
+ width?: number;
+ /*
+ 默认的伸缩状态
+ */
+ defaultCollapsed?: boolean;
+ /*
+ 控制展开与收缩
+ */
+ collapsed?: boolean;
+ /*
+ 当伸缩状态变化时的回调函数
+ */
+ onCollapse?: (collapsed: boolean) => void;
+ /*
+ 悬浮式侧边栏的位置
+ */
+ suspendedPosition?: 'left' | 'right';
+}
+
+export interface SiderState {
+ id: string;
+ width: number;
+ collapsedWidth: number;
+ suspendedPosition?: 'left' | 'right';
+}
+
+export interface ContentState {
+ maxWidth: number;
+ margin: number;
+}
+
+export interface LayoutState {
+ wide: boolean;
+}
+
+export interface LayoutContextType {
+ layoutState: LayoutState;
+ contentState: ContentState;
+ setLayoutState: (layoutState: Partial | (() => Partial)) => void;
+ setContentState: (contentState: Partial | (() => Partial)) => void;
+ removeSider: (siderId: string) => void;
+ updateSiders: (sider: SiderState) => void;
+}
diff --git a/src/components/layout/layout.tsx b/src/components/layout/layout.tsx
new file mode 100644
index 0000000000..84e2e7f08c
--- /dev/null
+++ b/src/components/layout/layout.tsx
@@ -0,0 +1,61 @@
+import React, { useLayoutEffect, useRef, createContext } from 'react';
+import classNames from 'classnames';
+import { useWindowSize, useSetState } from 'react-use';
+import { LayoutProps, LayoutState, ContentState, LayoutContextType } from './interfaces';
+import usePrefixCls from '../../utils/hooks/use-prefix-cls';
+import Header from './header';
+import Content from './content';
+import Sider from './sider';
+import useSiders from './useSiders';
+
+const initLayoutState = { wide: false };
+const initContentState = { maxWidth: 0, margin: 0 };
+
+export const LayoutContext = createContext({
+ layoutState: initLayoutState,
+ contentState: initContentState,
+} as LayoutContextType);
+
+const Layout = ({ prefixCls: customizePrefixCls, className, style, children }: LayoutProps) => {
+ const { width } = useWindowSize();
+ const containerRef = useRef(null);
+ const prefixCls = usePrefixCls('layout', customizePrefixCls);
+ const [localLayoutState, setLayoutState] = useSetState(initLayoutState);
+ const [localContentState, setContentState] = useSetState(initContentState);
+ const [siders, sidersWidth, removeSider, updateSiders, margin] = useSiders();
+
+ useLayoutEffect(() => {
+ const layoutWidth = containerRef.current?.getBoundingClientRect().width ?? 0;
+ setLayoutState(() => {
+ return { wide: layoutWidth > localContentState.maxWidth + 2 * localContentState.margin + sidersWidth };
+ });
+ }, [width, setLayoutState, localContentState, siders, sidersWidth]);
+
+ const mergedStyle = {
+ ...{ marginLeft: margin[0], marginRight: margin[1] },
+ ...style,
+ };
+
+ return (
+
+
+
+ );
+};
+
+Layout.Header = Header;
+Layout.Content = Content;
+Layout.Sider = Sider;
+
+export default Layout;
diff --git a/src/components/layout/sider.tsx b/src/components/layout/sider.tsx
new file mode 100644
index 0000000000..9281e2b503
--- /dev/null
+++ b/src/components/layout/sider.tsx
@@ -0,0 +1,78 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import React, { useContext, useEffect, useRef } from 'react';
+import classNames from 'classnames';
+import { LayoutSiderProps } from './interfaces';
+import usePrefixCls from '../../utils/hooks/use-prefix-cls';
+import { LayoutContext } from './layout';
+import useControlledState from '../../utils/hooks/useControlledState';
+
+const generateId = (() => {
+ let i = 0;
+ return () => {
+ i += 1;
+ return `'sider'-${i}`;
+ };
+})();
+
+const Sider = ({
+ prefixCls: customizePrefixCls,
+ className,
+ style,
+ children,
+ collapsedWidth = 80,
+ width = 200,
+ collapsed,
+ defaultCollapsed = true,
+ onCollapse,
+ suspendedPosition,
+}: LayoutSiderProps) => {
+ const { removeSider, updateSiders } = useContext(LayoutContext);
+ const [localCollapsed] = useControlledState(collapsed, defaultCollapsed);
+ const siderRef = useRef(null);
+ const siderId = useRef();
+
+ useEffect(() => {
+ const uniqueId = generateId();
+ siderId.current = uniqueId;
+ return () => removeSider(uniqueId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ const siderWidth = siderRef.current?.getBoundingClientRect().width ?? 0;
+ updateSiders({
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ id: siderId.current!,
+ width: suspendedPosition || localCollapsed ? collapsedWidth : siderWidth,
+ collapsedWidth,
+ suspendedPosition,
+ });
+ }, [collapsedWidth, localCollapsed, suspendedPosition, updateSiders]);
+
+ useEffect(() => onCollapse?.(localCollapsed), [localCollapsed, onCollapse]);
+
+ const prefixCls = usePrefixCls('layout-sider', customizePrefixCls);
+ const mergedStyle: React.CSSProperties = {
+ ['--layout-sider-width' as any]: `${width}px`,
+ ['--layout-sider-collapsedWidth' as any]: `${collapsedWidth}px`,
+ ['--layout-sider-collapsedWidth-negative' as any]: `-${collapsedWidth}px`,
+ ...style,
+ };
+
+ return (
+
+ );
+};
+
+export default Sider;
diff --git a/src/components/layout/style/index.less b/src/components/layout/style/index.less
new file mode 100644
index 0000000000..c2bac7dc5d
--- /dev/null
+++ b/src/components/layout/style/index.less
@@ -0,0 +1,46 @@
+@import '../../../stylesheet/index.less';
+
+@layout-prefix-cls: ~'@{component-prefix}-layout';
+
+.@{layout-prefix-cls} {
+ position: relative;
+ display: flex;
+ flex: auto;
+ flex-wrap: wrap;
+
+ &-header {
+ flex: 0 0 100%;
+ height: 48px;
+ }
+
+ &-content {
+ flex: var(--layout-content-grow) 1 var(--layout-content-maxWidth);
+ margin: 0 auto;
+ }
+
+ &-sider {
+ flex: 0 0 var(--layout-sider-width);
+ transition: all 0.2s;
+ &-collapsed {
+ flex: 0 0 var(--layout-sider-collapsedWidth);
+ }
+ }
+
+ &-sider-suspend {
+ position: absolute;
+ z-index: 1050;
+ width: var(--layout-sider-width);
+ height: 100%;
+ &-left {
+ left: 0;
+ transform: translateX(var(--layout-sider-collapsedWidth-negative));
+ }
+ &-right {
+ right: 0;
+ transform: translateX(var(--layout-sider-collapsedWidth));
+ }
+ &-collapsed {
+ width: var(--layout-sider-collapsedWidth);
+ }
+ }
+}
diff --git a/src/components/layout/style/index.ts b/src/components/layout/style/index.ts
new file mode 100644
index 0000000000..17665f8be2
--- /dev/null
+++ b/src/components/layout/style/index.ts
@@ -0,0 +1 @@
+import './index.less';
\ No newline at end of file
diff --git a/src/components/layout/useSiders.ts b/src/components/layout/useSiders.ts
new file mode 100644
index 0000000000..6a20ed431b
--- /dev/null
+++ b/src/components/layout/useSiders.ts
@@ -0,0 +1,39 @@
+import { useState, useMemo, useCallback } from 'react';
+import { SiderState } from './interfaces';
+
+const useSiders = (): [
+ SiderState[],
+ number,
+ (siderId: string) => void,
+ (sider: SiderState) => void,
+ [number, number]
+] => {
+ const [siders, setSiders] = useState([]);
+
+ const removeSider = (siderId: string) => {
+ setSiders((_siders) => _siders.filter((_sider) => _sider.id !== siderId));
+ };
+
+ const updateSiders = useCallback((incomingSider: SiderState) => {
+ setSiders((_siders) => {
+ _siders.filter((_sider) => _sider.id !== incomingSider.id);
+ return [..._siders, incomingSider];
+ });
+ }, []);
+
+ const sidersWidth = useMemo(() => siders.reduce((prev: number, current: SiderState) => prev + current.width, 0), [
+ siders,
+ ]);
+
+ const margin: [number, number] = useMemo(
+ () => [
+ siders.find((sider) => sider.suspendedPosition === 'left')?.collapsedWidth ?? 0,
+ siders.find((sider) => sider.suspendedPosition === 'right')?.collapsedWidth ?? 0,
+ ],
+ [siders]
+ );
+
+ return [siders, sidersWidth, removeSider, updateSiders, margin];
+};
+
+export default useSiders;