diff --git a/src/back/routes/common/tabs/tabs.css b/src/back/routes/common/tabs/tabs.css
new file mode 100644
index 0000000..214cf66
--- /dev/null
+++ b/src/back/routes/common/tabs/tabs.css
@@ -0,0 +1,22 @@
+.tabs {
+ & > nav input[type="radio"] {
+ display: none;
+ }
+
+ nav {
+ display: flex;
+ flex-flow: row nowrap;
+ gap: 8px;
+
+ .tabs__tab-label {
+ label {
+ cursor: pointer;
+ font-size: 16px;
+ user-select: none;
+ }
+ }
+ }
+ .tabs__tab {
+ display: none;
+ }
+}
diff --git a/src/back/routes/common/tabs/tabs.tsx b/src/back/routes/common/tabs/tabs.tsx
new file mode 100644
index 0000000..71bb2ff
--- /dev/null
+++ b/src/back/routes/common/tabs/tabs.tsx
@@ -0,0 +1,70 @@
+import type { FlatTemplatable } from "tempstream";
+import { TempstreamJSX } from "tempstream";
+
+const ids = (function* () {
+ let i = 0;
+ while (true) {
+ yield i++;
+ }
+})();
+
+export function tabs({
+ tabs,
+ default_tab,
+ tab_bar,
+ remember_tab = false,
+ active_navbar_tab_style = "",
+}: {
+ tabs: { id: string; label?: string; content: FlatTemplatable }[];
+ default_tab: string;
+ tab_bar?: FlatTemplatable;
+ remember_tab?: boolean;
+ active_navbar_tab_style?: string;
+}) {
+ const tab_section_id = ids.next().value;
+ return (
+
+ );
+}
diff --git a/src/back/routes/tabs-demo.page.tsx b/src/back/routes/tabs-demo.page.tsx
new file mode 100644
index 0000000..1cf8da4
--- /dev/null
+++ b/src/back/routes/tabs-demo.page.tsx
@@ -0,0 +1,48 @@
+import type { Context } from "koa";
+import { TempstreamJSX } from "tempstream";
+import { Page } from "@sealcode/sealgen";
+import html from "../html.js";
+import { tabs } from "./common/tabs/tabs.js";
+
+export const actionName = "TabsDemo";
+
+export default new (class TabsDemoPage extends Page {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ async canAccess(_: Context) {
+ return { canAccess: true, message: "" };
+ }
+
+ async render(ctx: Context) {
+ return html(
+ ctx,
+ "TabsDemo",
+
+ {tabs({
+ default_tab: "tab1",
+ active_navbar_tab_style:
+ "font-weight: bold; text-decoration: underline",
+ tabs: [
+ {
+ id: "tab1",
+ label: "First tab",
+ content: (
+
+
This is tab 1
+
+ ),
+ },
+ {
+ id: "tab2",
+ label: "Second tab",
+ content: (
+
+
And this is tab 2
+
+ ),
+ },
+ ],
+ })}
+
+ );
+ }
+})();
diff --git a/src/back/routes/tabs-demo.test.ts b/src/back/routes/tabs-demo.test.ts
new file mode 100644
index 0000000..7413bc7
--- /dev/null
+++ b/src/back/routes/tabs-demo.test.ts
@@ -0,0 +1,40 @@
+import { withProdApp } from "../test_utils/with-prod-app.js";
+import { VERY_LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint.js";
+import { TabsDemoURL } from "./urls.js";
+import { getBrowser } from "../test_utils/browser-creator.js";
+import type { Browser, BrowserContext, Page } from "@playwright/test";
+
+describe("TabsDemo webhint", () => {
+ it("doesn't crash", async function () {
+ return withProdApp(async ({ base_url, rest_api }) => {
+ await rest_api.get(TabsDemoURL);
+ await webhintURL(base_url + TabsDemoURL);
+ // alternatively you can use webhintHTML for faster but less precise scans
+ // or for scanning responses of requests that use some form of authorization:
+ // const response = await rest_api.get(TabsDemoURL);
+ // await webhintHTML(response);
+ });
+ }).timeout(VERY_LONG_TEST_TIMEOUT);
+});
+
+describe("TabsDemo", () => {
+ let page: Page;
+ let browser: Browser;
+ let context: BrowserContext;
+
+ beforeEach(async () => {
+ browser = await getBrowser();
+ context = await browser.newContext();
+ page = await context.newPage();
+ });
+
+ afterEach(async () => {
+ await context.close();
+ });
+
+ it("works as expected", async function () {
+ return withProdApp(async ({ base_url }) => {
+ await page.goto(base_url + TabsDemoURL);
+ });
+ }).timeout(VERY_LONG_TEST_TIMEOUT);
+});