diff --git a/ui/app/storybook/docs/components/or-vaadin-tabs.mdx b/ui/app/storybook/docs/components/or-vaadin-tabs.mdx
new file mode 100644
index 0000000000..da5452ae29
--- /dev/null
+++ b/ui/app/storybook/docs/components/or-vaadin-tabs.mdx
@@ -0,0 +1,30 @@
+{/* prettier-ignore-start */}
+
+{/*
+ * Copyright 2026, OpenRemote Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */}
+
+{/* prettier-ignore-end */}
+
+import { Meta } from "@storybook/addon-docs/blocks";
+import * as ComponentStory from "../../../../component/or-vaadin-components/stories/or-vaadin-tabs.stories"; // stories need relative imports
+import { ComponentDocs } from "../../util/componentdocs.mdx";
+
+
+
+
diff --git a/ui/component/or-vaadin-components/package.json b/ui/component/or-vaadin-components/package.json
index 426fcc4af9..e8aac0f78b 100644
--- a/ui/component/or-vaadin-components/package.json
+++ b/ui/component/or-vaadin-components/package.json
@@ -38,28 +38,29 @@
"@openremote/core": "workspace:*",
"@openremote/model": "workspace:*",
"@openremote/or-element": "workspace:*",
- "@vaadin/badge": "~25.3.2",
- "@vaadin/button": "~25.3.2",
- "@vaadin/checkbox": "~25.3.2",
- "@vaadin/checkbox-group": "~25.3.2",
- "@vaadin/combo-box": "~25.3.2",
- "@vaadin/confirm-dialog": "~25.3.2",
- "@vaadin/date-picker": "~25.3.2",
- "@vaadin/date-time-picker": "~25.3.2",
- "@vaadin/dialog": "~25.3.2",
- "@vaadin/item": "~25.3.2",
- "@vaadin/list-box": "~25.3.2",
- "@vaadin/menu-bar": "~25.3.2",
- "@vaadin/multi-select-combo-box": "~25.3.2",
- "@vaadin/number-field": "~25.3.2",
- "@vaadin/password-field": "~25.3.2",
- "@vaadin/radio-group": "~25.3.2",
- "@vaadin/select": "~25.3.2",
- "@vaadin/slider": "~25.3.2",
- "@vaadin/text-area": "~25.3.2",
- "@vaadin/text-field": "~25.3.2",
- "@vaadin/time-picker": "~25.3.2",
- "@vaadin/vaadin-themable-mixin": "~25.3.2",
+ "@vaadin/badge": "~25.3.3",
+ "@vaadin/button": "~25.3.3",
+ "@vaadin/checkbox": "~25.3.3",
+ "@vaadin/checkbox-group": "~25.3.3",
+ "@vaadin/combo-box": "~25.3.3",
+ "@vaadin/confirm-dialog": "~25.3.3",
+ "@vaadin/date-picker": "~25.3.3",
+ "@vaadin/date-time-picker": "~25.3.3",
+ "@vaadin/dialog": "~25.3.3",
+ "@vaadin/item": "~25.3.3",
+ "@vaadin/list-box": "~25.3.3",
+ "@vaadin/menu-bar": "~25.3.3",
+ "@vaadin/multi-select-combo-box": "~25.3.3",
+ "@vaadin/number-field": "~25.3.3",
+ "@vaadin/password-field": "~25.3.3",
+ "@vaadin/radio-group": "~25.3.3",
+ "@vaadin/select": "~25.3.3",
+ "@vaadin/slider": "~25.3.3",
+ "@vaadin/tabs": "~25.3.3",
+ "@vaadin/text-area": "~25.3.3",
+ "@vaadin/text-field": "~25.3.3",
+ "@vaadin/time-picker": "~25.3.3",
+ "@vaadin/vaadin-themable-mixin": "~25.3.3",
"i18next": "^26.3.6",
"lit": "^3.3.1"
},
diff --git a/ui/component/or-vaadin-components/src/index.ts b/ui/component/or-vaadin-components/src/index.ts
index 4cb3e07569..d7cd6d69b0 100644
--- a/ui/component/or-vaadin-components/src/index.ts
+++ b/ui/component/or-vaadin-components/src/index.ts
@@ -36,6 +36,7 @@ export * from "./or-vaadin-password-field";
export * from "./or-vaadin-radio-group";
export * from "./or-vaadin-select";
export * from "./or-vaadin-slider";
+export * from "./or-vaadin-tabs";
export * from "./or-vaadin-text-area";
export * from "./or-vaadin-text-field";
export * from "./or-vaadin-time-picker";
diff --git a/ui/component/or-vaadin-components/src/or-vaadin-tabs.ts b/ui/component/or-vaadin-components/src/or-vaadin-tabs.ts
new file mode 100644
index 0000000000..a5b9b3e680
--- /dev/null
+++ b/ui/component/or-vaadin-components/src/or-vaadin-tabs.ts
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2026, OpenRemote Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { customElement } from "lit/decorators.js";
+import { html, type LitElement } from "lit";
+import { styleMap } from "lit/directives/style-map.js";
+import { Tabs } from "@vaadin/tabs/vaadin-tabs.js";
+import { registerStyles, css } from "@vaadin/vaadin-themable-mixin/register-styles.js";
+import type { OrVaadinComponent } from "./util";
+
+/* Registers the tabs that go in the bar, and exports their class so projects can type them */
+export { Tab } from "@vaadin/tabs/vaadin-tab.js";
+
+/*
+ * Without a variant the theme gives this tag the Lumo tabs look, or the Vaadin base styles apply without it. The
+ * `sliding` variant replaces either look, and styles the tabs from here as the Lumo tabs variants do. Its tabs share the
+ * bar equally, so the indicator is sized from the number of tabs and moved by the index of the selected one.
+ */
+registerStyles(
+ "or-vaadin-tabs",
+ css`
+ :host([theme~="sliding"]) {
+ --_gap: var(--or-tabs-gap, 10px);
+ --_padding: var(--or-tabs-padding, var(--lumo-space-xs, 4px));
+ /* At most a full pill, and the bar curves around it so both orientations round alike */
+ --_tab-radius: min(
+ var(--or-tabs-radius, var(--lumo-border-radius-m, 6px)),
+ var(--or-tab-height, var(--lumo-size-l, 44px)) / 2
+ );
+ /* The indicator marks the selection instead of the Lumo underline */
+ --_lumo-tab-marker-display: none;
+ position: relative;
+ display: flex;
+ box-sizing: border-box;
+ padding: var(--_padding);
+ border-radius: calc(var(--_tab-radius) + var(--_padding));
+ background: var(--or-tabs-background, var(--lumo-contrast-5pct, #f2f3f2));
+ box-shadow: none;
+ }
+
+ :host([theme~="sliding"][orientation="vertical"]) {
+ flex-direction: column;
+ }
+
+ :host([theme~="sliding"]) [part="tabs"] {
+ display: flex;
+ flex-direction: row;
+ gap: var(--_gap);
+ margin: 0;
+ }
+
+ :host([theme~="sliding"][orientation="vertical"]) [part="tabs"] {
+ flex-direction: column;
+ }
+
+ :host([theme~="sliding"]) [part="tabs"] ::slotted(vaadin-tab) {
+ flex: 1 1 0;
+ justify-content: center;
+ min-width: 0;
+ min-height: var(--or-tab-height, var(--lumo-size-l, 44px));
+ box-sizing: border-box;
+ margin: 0;
+ border-radius: var(--_tab-radius);
+ background: transparent;
+ text-align: center;
+ overflow-wrap: anywhere;
+ transition: color var(--or-tabs-transition-duration, 0.3s) ease-out;
+ }
+
+ :host(:not([theme~="sliding"])) #indicator {
+ display: none;
+ }
+
+ #indicator {
+ position: absolute;
+ inset-block: var(--_padding);
+ inset-inline-start: var(--_padding);
+ width: calc((100% - 2 * var(--_padding) - (var(--_count) - 1) * var(--_gap)) / var(--_count));
+ border-radius: var(--_tab-radius);
+ background: var(--or-tabs-indicator-color, var(--lumo-base-color, #ffffff));
+ translate: calc(var(--_direction, 1) * var(--_index) * (100% + var(--_gap))) 0;
+ transition: translate var(--or-tabs-transition-duration, 0.3s) ease-out;
+ }
+
+ :host([dir="rtl"]) #indicator {
+ --_direction: -1;
+ }
+
+ :host([orientation="vertical"]) #indicator {
+ inset-inline: var(--_padding);
+ inset-block-end: auto;
+ width: auto;
+ height: calc((100% - 2 * var(--_padding) - (var(--_count) - 1) * var(--_gap)) / var(--_count));
+ translate: 0 calc(var(--_index) * (100% + var(--_gap)));
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ #indicator,
+ :host([theme~="sliding"]) [part="tabs"] ::slotted(vaadin-tab) {
+ transition: none;
+ }
+ }
+ `
+);
+
+/**
+ * A row of `` elements, with the Lumo look and variants of ``. Set `theme="sliding"` for a
+ * more mobile-friendly bar with a contrasting background, where the tabs share the width equally and an indicator
+ * slides behind the selected tab. The `--or-*` properties style that variant, and its tabs keep the Lumo text styles,
+ * which plain CSS on `vaadin-tab` overrides. Hidden tabs are left out of its layout, but only when they are hidden
+ * before they are added or the selection changes.
+ *
+ * @customElement "or-vaadin-tabs"
+ * @slot - The `` elements.
+ * @attr {string} orientation - `horizontal` (default) or `vertical`.
+ * @attr {string} theme - Set to `sliding` for the bar with an indicator that slides behind the selected tab, or to a
+ * Lumo variant of ``. Sliding tabs always fit the bar and take precedence over the Lumo variants they
+ * conflict with, so `hide-scroll-buttons` and `show-scroll-buttons` have no effect with `sliding`.
+ * @fires {CustomEvent} selected-changed - Fired when the `selected` property changes.
+ * @fires {CustomEvent} items-changed - Fired when the `items` property changes.
+ * @cssprop --or-tabs-background - Background of the bar.
+ * @cssprop --or-tabs-indicator-color - Color of the indicator behind the selected tab.
+ * @cssprop --or-tabs-padding - Space between the bar edge and the tabs.
+ * @cssprop --or-tabs-gap - Space between the tabs.
+ * @cssprop --or-tabs-radius - Corner radius of the tabs and the indicator, at most a full pill. The bar's corners
+ * follow it around the padding. Defaults to the Lumo medium radius.
+ * @cssprop --or-tabs-transition-duration - Duration of the indicator slide and the label color change.
+ * @cssprop --or-tab-height - Minimum height of the tabs, which also caps their rounding at a full pill.
+ */
+@customElement("or-vaadin-tabs")
+export class OrVaadinTabs extends (Tabs as new () => Tabs & LitElement) implements OrVaadinComponent {
+ static get is() {
+ return "or-vaadin-tabs";
+ }
+
+ protected override render() {
+ const items = this.items ?? [];
+ const visible = items.filter((item) => !(item as HTMLElement).hidden);
+ const selected = this.selected == null ? undefined : items[this.selected];
+ const index = selected ? visible.indexOf(selected) : -1;
+
+ return html`
+
+ ${super.render()}
+ `;
+ }
+}
diff --git a/ui/component/or-vaadin-components/stories/or-vaadin-tabs.stories.ts b/ui/component/or-vaadin-components/stories/or-vaadin-tabs.stories.ts
new file mode 100644
index 0000000000..ec2b4baf0f
--- /dev/null
+++ b/ui/component/or-vaadin-components/stories/or-vaadin-tabs.stories.ts
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2026, OpenRemote Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { setCustomElementsManifest, type Meta, type StoryObj } from "@storybook/web-components";
+import { getORStorybookHelpers } from "../../storybook-utils";
+import customElements from "../build/custom-elements.json" with { type: "json" };
+import packageJson from "../package.json" with { type: "json" };
+import { html } from "lit";
+import { styleMap } from "lit/directives/style-map.js";
+import "../src/or-vaadin-tabs";
+import type { OrVaadinTabs } from "../src/or-vaadin-tabs";
+
+const tagName = "or-vaadin-tabs";
+type Story = StoryObj;
+setCustomElementsManifest(customElements);
+
+const { events, args, argTypes, template } = getORStorybookHelpers(tagName);
+
+const pages = [
+ { name: "Status", content: "Everything is running normally. The last reading came in a minute ago." },
+ { name: "Mode", content: "The asset runs automatically and follows its schedule." },
+ { name: "Settings", content: "Change the name, location and schedule of the asset." },
+];
+
+/** Tabs for the pages, without content. */
+const plainTabs = () => pages.map(({ name }) => html`${name}`);
+
+/** Selects the tab in every bar of the `data-tab-group` and shows its page in the group's `
`. */
+const showContent = (event: CustomEvent<{ value: number }>) => {
+ const group = (event.target as Element).closest("[data-tab-group]");
+ group?.querySelectorAll("or-vaadin-tabs").forEach((tabs) => (tabs.selected = event.detail.value));
+ const content = group?.querySelector("p");
+ if (content) {
+ content.textContent = pages[event.detail.value]?.content ?? "";
+ }
+};
+
+/** An example with a caption naming what it sets. */
+const captioned = (caption: string, example: unknown) => html`
+
+
+ ${caption}
+
+ ${example}
+
+`;
+
+const meta: Meta = {
+ title: "Playground/or-vaadin-components/tabs",
+ component: tagName,
+ args,
+ argTypes,
+ render: (storyArgs) => template(storyArgs, html`${plainTabs()}`),
+ excludeStories: /^[a-z].*/,
+ parameters: {
+ actions: {
+ handles: events,
+ },
+ docs: {
+ subtitle: `<${tagName}>`,
+ description:
+ 'Tabs switch between views or pages. They have the Lumo look of `` and work the same, so see the [Vaadin Tabs docs](https://vaadin.com/docs/latest/components/tabs) for their usage and [style variants](https://vaadin.com/docs/latest/components/tabs/styling). Set `theme="sliding"` for a more mobile-friendly bar with a contrasting background, where the `` elements share the width equally and an indicator slides behind the selected one. Its colors, sizes, spacing, rounding and timing are set through `--or-tabs-*` and `--or-tab-*` properties.',
+ },
+ },
+};
+
+export const Primary: Story = {
+ args: {
+ selected: 0,
+ },
+};
+
+/** The sliding variant, horizontally and vertically. */
+export const Sliding: Story = {
+ name: "Sliding",
+ parameters: {
+ title: "Sliding",
+ summary:
+ 'The sliding variant is a more mobile-friendly alternative to the default tabs, for switching between a few views on a phone. Set `theme="sliding"` for a bar with a contrasting background, where the tabs share its width as large touch targets and an indicator slides behind the selected one. With `orientation="vertical"` the indicator slides vertically. The two bars here share one set of content, as selecting a tab in one sets `selected` on the other from its `selected-changed` event.',
+ docs: {
+ story: {
+ height: "260px",
+ },
+ },
+ },
+ render: () => html`
+
+ ${plainTabs()}
+
+
+ ${plainTabs()}
+
+
${pages[0].content}
+
+
+ `,
+};
+
+const roundings = [
+ { caption: "0px", radius: "0px" },
+ { caption: "3px", radius: "3px" },
+ { caption: "Default, the Lumo medium radius", radius: undefined },
+ { caption: "12px", radius: "12px" },
+ { caption: "99px, a full pill", radius: "99px" },
+];
+
+/** The sliding variant with a range of `--or-tabs-radius` values. */
+export const Rounding: Story = {
+ name: "Rounding",
+ parameters: {
+ title: "Rounding",
+ summary:
+ "`--or-tabs-radius` rounds the tabs and the indicator, up to a full pill, and the corners of the bar follow it around the padding. The rounding is the same in either `orientation`.",
+ docs: {
+ story: {
+ height: "760px",
+ },
+ },
+ },
+ render: () => html`
+