+ );
+ },
+};
diff --git a/src/components/Nav/NavBar.test.tsx b/src/components/Nav/NavBar.test.tsx
new file mode 100644
index 00000000..613955a0
--- /dev/null
+++ b/src/components/Nav/NavBar.test.tsx
@@ -0,0 +1,36 @@
+/*
+Copyright 2023 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { describe, it, expect } from "vitest";
+import { render } from "@testing-library/react";
+import React from "react";
+import { composeStories } from "@storybook/react";
+
+import * as stories from "./NavBar.stories";
+
+const { Default, TabRole } = composeStories(stories);
+
+describe("", () => {
+ it("render a default nav bar", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("render a tabbed nav bar", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/components/Nav/NavBar.tsx b/src/components/Nav/NavBar.tsx
new file mode 100644
index 00000000..6b7816dd
--- /dev/null
+++ b/src/components/Nav/NavBar.tsx
@@ -0,0 +1,87 @@
+// Copyright 2023 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from "react";
+import classNames from "classnames";
+
+import styles from "./Nav.module.css";
+
+type NavBarProps = {
+ /**
+ * The CSS class name
+ */
+ className?: string;
+
+ /**
+ * Require a label for navigation landmarks
+ */
+ "aria-label": string;
+
+ /**
+ * Accessibility role that defaults to navigation.
+ *
+ * If you pass tablist you must also pass `aria-controls` as prop to your NavItem and must
+ * ensure that the conditions stated in https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#wai-ariaroles,states,andproperties
+ * are met.
+ */
+ role?: "navigation" | "tablist";
+};
+
+/**
+ * A navigation bar component
+ */
+export const NavBar = ({
+ children,
+ className,
+ role,
+ "aria-label": ariaLabel,
+ ...rest
+}: React.PropsWithChildren) => {
+ const classes = classNames(className, styles["nav-bar"]);
+ /**
+ * We sometimes want to use this NavBar for tabs.
+ * This is done by passing `role=tablist` to this component.
+ * By default, this component is used as a navigation bar.
+ *
+ * Depending on this role, a different set of accessibility
+ * attributes need to be applied to the nav/ul element.
+ */
+ const a11yAttributesForNav =
+ role !== "tablist"
+ ? /**
+ * If role isn't tablist, default to navigation.
+ */
+ { role: "navigation", "aria-label": ariaLabel }
+ : /**
+ * If role is tablist, give nav presentation role to remove
+ * any semantic meaning.
+ */
+ { role: "presentation" };
+
+ /**
+ * When used as tabs, the tablist role must be applied to ul.
+ * When used as navigation, no special accessibility attribute
+ * is needed for the ul element.
+ */
+ const a11yAttributesForUl =
+ role === "tablist" ? { role: "tablist", "aria-label": ariaLabel } : {};
+
+ return (
+
+ );
+};
diff --git a/src/components/Nav/NavItem.stories.tsx b/src/components/Nav/NavItem.stories.tsx
new file mode 100644
index 00000000..829e8799
--- /dev/null
+++ b/src/components/Nav/NavItem.stories.tsx
@@ -0,0 +1,88 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+Copyright 2023 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import { NavItem } from "./NavItem";
+import { NavBar } from ".";
+import { StoryFn } from "@storybook/react";
+
+export default {
+ title: "Nav/Nav Item",
+ component: NavItem,
+ tags: ["autodocs"],
+ argTypes: {
+ onClick: {
+ action: "clicked",
+ },
+ },
+ decorators: [
+ (Story: StoryFn) => (
+
+
+
+ ),
+ ],
+ parameters: {
+ design: {
+ type: "figma",
+ url: "https://www.figma.com/file/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?type=design&node-id=669-2723&mode=design&t=9Hy0h7BBDH0kJ2Ow-0",
+ },
+ },
+};
+
+export const Default = {
+ args: {
+ children: "Sessions",
+ },
+};
+
+export const Disabled = {
+ args: {
+ children: "Sessions",
+ disabled: true,
+ },
+};
+
+export const Link = {
+ args: {
+ children: "Sessions",
+ href: "https://example.org",
+ },
+};
+
+export const Active = {
+ args: {
+ children: "Sessions",
+ active: true,
+ },
+};
+
+export const ActiveLink = {
+ args: {
+ ...Link.args,
+ active: true,
+ },
+};
+
+export const ActiveDisabled = {
+ args: {
+ children: "Sessions",
+ active: true,
+ disabled: true,
+ },
+};
diff --git a/src/components/Nav/NavItem.test.tsx b/src/components/Nav/NavItem.test.tsx
new file mode 100644
index 00000000..a8b955e7
--- /dev/null
+++ b/src/components/Nav/NavItem.test.tsx
@@ -0,0 +1,53 @@
+/*
+Copyright 2023 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { describe, it, expect } from "vitest";
+import { render } from "@testing-library/react";
+import React from "react";
+import { composeStories } from "@storybook/react";
+
+import * as stories from "./NavItem.stories";
+
+const { Default, Disabled, Link, Active, ActiveLink, ActiveDisabled } =
+ composeStories(stories);
+
+describe("", () => {
+ it("render a default item", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("render a Disabled item", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+ it("render a Link item", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+ it("render a Active item", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+ it("render a ActiveLink item", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+ it("render a ActiveDisabled item", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/components/Nav/NavItem.tsx b/src/components/Nav/NavItem.tsx
new file mode 100644
index 00000000..5238f5c6
--- /dev/null
+++ b/src/components/Nav/NavItem.tsx
@@ -0,0 +1,126 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+Copyright 2023 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, {
+ AnchorHTMLAttributes,
+ ButtonHTMLAttributes,
+ MouseEventHandler,
+} from "react";
+import type { XOR } from "ts-xor";
+
+import styles from "./Nav.module.css";
+
+type NavItemProps = {
+ active?: boolean;
+ "aria-controls"?: string;
+};
+
+type NavItemLinkProps = Omit<
+ AnchorHTMLAttributes,
+ "style" | "className"
+> & {
+ href: string;
+} & NavItemProps;
+
+type NavItemButtonProps = Omit<
+ ButtonHTMLAttributes,
+ "style" | "className"
+> & {
+ onClick: MouseEventHandler;
+} & NavItemProps;
+
+const NavItemLink = ({
+ children,
+ href,
+ onClick,
+ ...rest
+}: React.PropsWithChildren) => (
+
+ {children}
+
+);
+
+const NavItemButton = ({
+ children,
+ disabled,
+ onClick,
+ ...rest
+}: React.PropsWithChildren) => (
+
+);
+
+const renderAsLink = (
+ props: React.PropsWithChildren>,
+): props is React.PropsWithChildren => !!props.href;
+
+/**
+ * A navigation item component to be used with a navigation bar.
+ * Will render an anchor when href is provided, otherwise a button element.
+ */
+export const NavItem = (
+ props: React.PropsWithChildren>,
+) => {
+ /**
+ * For accessibility rules related to tabs,
+ * see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#wai-ariaroles,states,andproperties
+ *
+ * For accessibility rules related to navigation,
+ * see https://www.digitala11y.com/navigation-role/
+ */
+ const ariaControls = props["aria-controls"];
+ const isUsedAsTabs = !!ariaControls;
+ const a11yAttributes = isUsedAsTabs
+ ? {
+ // when used as tabs
+ "aria-controls": ariaControls,
+ role: "tab",
+ "aria-selected": props.active,
+ }
+ : {
+ // when used as navigation elements
+ "aria-current": props.active ? true : undefined,
+ };
+
+ // All the attributes except `active` can be passed to the button/a element.
+ const propsForChild = { ...props };
+ delete propsForChild["active"];
+
+ // Depending on whether `href` is in props, we render link/button
+ let navItem: React.ReactNode;
+ if (renderAsLink(propsForChild)) {
+ navItem = ;
+ } else {
+ navItem = ;
+ }
+
+ return (
+
+ {navItem}
+
+ );
+};
diff --git a/src/components/Nav/__snapshots__/NavBar.test.tsx.snap b/src/components/Nav/__snapshots__/NavBar.test.tsx.snap
new file mode 100644
index 00000000..a9789d73
--- /dev/null
+++ b/src/components/Nav/__snapshots__/NavBar.test.tsx.snap
@@ -0,0 +1,136 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > render a default nav bar 1`] = `
+
+
+
+`;
+
+exports[` > render a tabbed nav bar 1`] = `
+
+
+
+
+ This is panel 1
+
+
+ This is panel 2
+
+
+ This is panel 3
+
+
+
+`;
diff --git a/src/components/Nav/__snapshots__/NavItem.test.tsx.snap b/src/components/Nav/__snapshots__/NavItem.test.tsx.snap
new file mode 100644
index 00000000..7113a298
--- /dev/null
+++ b/src/components/Nav/__snapshots__/NavItem.test.tsx.snap
@@ -0,0 +1,161 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > render a Active item 1`] = `
+
+`;
diff --git a/src/components/Nav/index.ts b/src/components/Nav/index.ts
new file mode 100644
index 00000000..fc6577a6
--- /dev/null
+++ b/src/components/Nav/index.ts
@@ -0,0 +1,16 @@
+// Copyright 2023 The Matrix.org Foundation C.I.C.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export { NavBar } from "./NavBar";
+export { NavItem } from "./NavItem";
diff --git a/src/index.ts b/src/index.ts
index 8f788f3d..85d3cdc3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -38,6 +38,7 @@ export {
} from "./components/Typography/Heading";
export { IndicatorIcon } from "./components/Icon/IndicatorIcon/IndicatorIcon";
export { Link } from "./components/Link/Link";
+export { NavBar, NavItem } from "./components/Nav";
export { Menu } from "./components/Menu/Menu";
export { MenuItem } from "./components/Menu/MenuItem";
export { Search } from "./components/Search/Search";
diff --git a/yarn.lock b/yarn.lock
index ae48c58c..7adccf41 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9461,6 +9461,11 @@ ts-dedent@^2.0.0, ts-dedent@^2.2.0:
resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5"
integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==
+ts-xor@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/ts-xor/-/ts-xor-1.3.0.tgz#3e59f24f0321f9f10f350e0cee3b534b89a2c70b"
+ integrity sha512-RLXVjliCzc1gfKQFLRpfeD0rrWmjnSTgj7+RFhoq3KRkUYa8LE/TIidYOzM5h+IdFBDSjjSgk9Lto9sdMfDFEA==
+
tsconfig-paths@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz#ef78e19039133446d244beac0fd6a1632e2d107c"