Skip to Content
UI playbookShell & page header

Shell & page header

Shell — ProLayout with react-router

// app/components/shell.tsx — used by app/layout.tsx for signed-in routes import { ProLayout } from "@ant-design/pro-components"; import { Dropdown, Tag, theme } from "antd"; import { Navigate, Outlet, useLocation, useNavigate } from "react-router"; export function Shell() { const { token } = theme.useToken(); const nav = useNavigate(); const { pathname } = useLocation(); const { user, signOut } = useSession(); // your auth context if (!user) return <Navigate to="/login" replace />; return ( <ProLayout title="Duyệt mua sắm" logo={<AuditOutlined style={{ fontSize: 24, color: token.colorPrimary }} />} layout="mix" fixSiderbar fixedHeader siderWidth={220} location={{ pathname: "/" + (pathname.split("/")[1] ?? "") }} // highlight parent menu on detail routes route={{ path: "/", routes: [ { path: "/", name: "Tổng quan", icon: <DashboardOutlined /> }, { path: "/requests", name: "Đề xuất", icon: <FileTextOutlined /> }, ] }} menuItemRender={(item, dom) => <a onClick={() => item.path && nav(item.path)}>{dom}</a>} breadcrumbRender={false} avatarProps={{ size: "small", title: user.name, children: user.name[0], render: (_, dom) => <Dropdown menu={{ items: [{ key: "out", label: "Đăng xuất", onClick: signOut }] }}>{dom}</Dropdown> }} actionsRender={(p) => (p.isMobile ? [] : [<Tag key="role" variant="filled" color="gold">Quản lý</Tag>])} token={{ header: { colorBgHeader: token.colorBgContainer }, sider: { colorMenuBackground: token.colorBgContainer, colorBgMenuItemSelected: token.colorPrimaryBg, colorTextMenuSelected: token.colorPrimary } }} > <Outlet /> </ProLayout> ); }
  • Menu items come from route (filter by role before passing). location is required or nothing highlights.
  • Public pages (/login, /register) render without the shell — decide in app/layout.tsx by useLocation().pathname.
  • Don’t: hand-roll Layout.Sider + Menu; override .ant-pro-* classes; ship SettingDrawer.

App launcher, mix layout, grouped menus

The Ant Design Pro look (top bar with first-level menu, left sider with group titles, 9-dot app switcher, icons + avatar on the right) is all ProLayout props — no custom CSS:

<ProLayout layout="mix" splitMenus // level 1 in the header, levels 2–3 in the sider siderMenuType="group" // level 2 renders as group titles, level 3 as items menu={{ collapsedShowGroupTitle: true, locale: false }} route={{ path: "/", children: [ { path: "/ops", name: "Vận hành", icon: <AppstoreOutlined />, children: [ { path: "/ops/orders", name: "Đơn hàng", children: [{ path: "/ops/orders/all", name: "Tất cả đơn" }] }, ] }, ] }} appList={[ // 9-dot launcher next to the logo { title: "Nội bộ", desc: "Ứng dụng của công ty", children: [ { title: "CRM", desc: "Khách hàng & cơ hội", icon: <TeamOutlined />, url: "https://crm.example.vn", target: "_blank" }, ] }, ]} itemClick={(item, popoverRef) => { /* optional: handle in-app; popoverRef.current?.close() */ }} bgLayoutImgList={[{ src: "…/bg1.png", left: 85, bottom: 100, height: "303px" }]} // soft background art actionsRender={(p) => (p.isMobile ? [] : [<QuestionCircleFilled key="q" />])} avatarProps={{ size: "small", title: user.name, render: (_, dom) => <Dropdown menu={{ items }}>{dom}</Dropdown> }} menuFooterRender={(p) => (p?.collapsed ? undefined : <div>v1.0</div>)} />
  • appList: items with children render as titled groups. Card mode (big tiles) is chosen when every item has desc; otherwise the compact icon grid.
  • splitMenus only makes sense with layout="mix"; the active top item is derived from location.pathname, so location must be set.
  • menuItemRender is required without umi — without it menu clicks do nothing. Use useNavigate().
  • breakpoint (default lg) auto-collapses the sider; collapsed + onCollapse to control it.
  • ProLayout does not enforce access — filter route by role yourself.
  • title defaults to “Ant Design Pro” — always set it (and logo).

Khung app ProLayout — Khung app: ProLayout mix + menu nhóm 3 cấp + app launcher 9 chấm + icon header + avatar. Mở trong Playground →

Xem code (90 dòng)
// Khung app: ProLayout mix + menu nhóm 3 cấp + app launcher 9 chấm + icon header + avatar.
// Trong app thật: menuItemRender dùng useNavigate()/<Link>, location = useLocation().
import { useState } from "react";
import { PageContainer, ProCard, ProLayout } from "@ant-design/pro-components";
import { Dropdown, Typography, theme } from "antd";
import {
  AppstoreOutlined, AuditOutlined, BarChartOutlined, FileTextOutlined, GithubFilled,
  InfoCircleFilled, LogoutOutlined, QuestionCircleFilled, ShoppingOutlined, TeamOutlined,
} from "@ant-design/icons";

const route = {
  path: "/",
  children: [
    {
      path: "/ops", name: "Vận hành", icon: <AppstoreOutlined />,
      children: [
        { path: "/ops/orders", name: "Đơn hàng", icon: <ShoppingOutlined />, children: [
          { path: "/ops/orders/all", name: "Tất cả đơn" },
          { path: "/ops/orders/pending", name: "Chờ xử lý" },
        ] },
        { path: "/ops/requests", name: "Đề xuất mua sắm", icon: <FileTextOutlined />, children: [
          { path: "/ops/requests/mine", name: "Của tôi" },
          { path: "/ops/requests/inbox", name: "Cần tôi duyệt" },
        ] },
      ],
    },
    {
      path: "/report", name: "Báo cáo", icon: <BarChartOutlined />,
      children: [
        { path: "/report/sales", name: "Doanh số", icon: <BarChartOutlined />, children: [
          { path: "/report/sales/month", name: "Theo tháng" },
        ] },
      ],
    },
  ],
};

const appList = [
  { title: "Nội bộ", desc: "Ứng dụng của công ty", children: [
    { title: "CRM", desc: "Khách hàng & cơ hội", icon: <TeamOutlined />, url: "#crm" },
    { title: "Kho", desc: "Tồn kho & phiếu xuất", icon: <ShoppingOutlined />, url: "#kho" },
  ] },
  { title: "Công cụ", desc: "Dùng chung", children: [
    { title: "Duyệt mua sắm", desc: "Đề xuất & phê duyệt", icon: <AuditOutlined />, url: "#duyet" },
  ] },
];

export default function Page() {
  const { token } = theme.useToken();
  const [pathname, setPathname] = useState("/ops/orders/all");
  return (
    <div style={{ height: "100vh" }}>
      <ProLayout
        title="Acme Admin"
        logo={<AuditOutlined style={{ fontSize: 22, color: token.colorPrimary }} />}
        layout="mix"
        splitMenus
        siderMenuType="group"
        menu={{ collapsedShowGroupTitle: true, locale: false }}
        fixSiderbar
        route={route}
        location={{ pathname }}
        menuItemRender={(item, dom) => <a onClick={() => item.path && setPathname(item.path)}>{dom}</a>}
        appList={appList}
        bgLayoutImgList={[
          { src: "https://img.alicdn.com/imgextra/i2/O1CN01O4etvp1DvpFLKfuWq_!!6000000000279-2-tps-609-606.png", left: 85, bottom: 100, height: "303px" },
          { src: "https://img.alicdn.com/imgextra/i3/O1CN018NxReL1shX85Yz6Cx_!!6000000005798-2-tps-884-496.png", bottom: 0, left: 0, width: "331px" },
        ]}
        actionsRender={(p) => (p.isMobile ? [] : [<InfoCircleFilled key="i" />, <QuestionCircleFilled key="q" />, <GithubFilled key="g" />])}
        avatarProps={{
          size: "small",
          title: "Nguyễn Thu Hà",
          children: "H",
          style: { background: token.colorPrimary },
          render: (_, dom) => <Dropdown menu={{ items: [{ key: "out", icon: <LogoutOutlined />, label: "Đăng xuất" }] }}>{dom}</Dropdown>,
        }}
        menuFooterRender={(p) => (p?.collapsed ? undefined : <div style={{ textAlign: "center", paddingBlockStart: 12, color: token.colorTextTertiary }}>v1.0 · Acme</div>)}
      >
        <PageContainer subTitle={pathname}>
          <ProCard>
            <Typography.Paragraph>
              Bấm các mục menu (trên: nhóm cấp 1, trái: tiêu đề nhóm cấp 2 + mục cấp 3) và nút 9 chấm cạnh logo.
            </Typography.Paragraph>
          </ProCard>
        </PageContainer>
      </ProLayout>
    </div>
  );
}

Page header — PageContainer

<PageContainer header={{ title: r.title, breadcrumb: { items: [{ title: <a onClick={() => nav("/requests")}>Đề xuất</a> }, { title: `#${r.id}` }] }, tags: <Tag color="processing">Chờ duyệt</Tag>, extra: <Space wrap>{ownerActions}</Space> }} content={<ProDescriptions column={{ xs: 1, sm: 2, lg: 4 }}>{/* 3–4 key facts */}</ProDescriptions>} extraContent={<Statistic title="Số tiền đề xuất" value={vnd(r.amount)} />} // the one number that matters footer={pending && manager ? [<RejectButton key="r" />, <ApproveButton key="a" />] : undefined} // → sticky FooterToolbar >
  • content = summary strip under the title; extraContent = 1–3 right-aligned numbers; footer = the decision buttons, always visible.
  • tabList + tabActiveKey + onTabChange for detail pages with several aspects (info / history / related).
  • Dashboards: title = greeting, subTitle = the to-do count (“2 đề xuất đang chờ bạn duyệt”), one primary extra button.
Last updated on