diff --git a/docs/kit/json-render.md b/docs/kit/json-render.md
index 1c546c5c..36ad9a1a 100644
--- a/docs/kit/json-render.md
+++ b/docs/kit/json-render.md
@@ -273,6 +273,40 @@ Container with an optional title and collapsible behavior.
}
```
+#### Tabs
+
+Switches which of its `children` is shown, one tab per entry in `tabs` (positionally matched — `children[i]` renders when `tabs[i]` is active).
+
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `tabs` | `Array<{ value: string, label: string, icon?: string, badge?: string, badgeVariant?: 'default' \| 'info' \| 'success' \| 'warning' \| 'danger' }>` | — | The tab list |
+| `value` | `string` | — | Active tab's `value` (use `$bindState` for two-way binding) |
+| `defaultValue` | `string` | first tab | Initial active tab when `value` isn't bound |
+| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Underlined top bar vs. a left-hand rail |
+
+**Event**: `change` — fires when the active tab changes (including via arrow-key navigation).
+
+
+```ts
+{
+ root: 'root',
+ elements: {
+ root: {
+ type: 'Tabs',
+ props: {
+ tabs: [
+ { value: 'mfe', label: 'Micro-Frontends', badge: '3', badgeVariant: 'success' },
+ { value: 'gateway', label: 'Gateway' },
+ ],
+ },
+ children: ['mfe-panel', 'gateway-panel'],
+ },
+ 'mfe-panel': { type: 'Text', props: { text: '3 active overrides' } },
+ 'gateway-panel': { type: 'Text', props: { text: 'No overrides' } },
+ },
+}
+```
+
#### Divider
Visual separator line with an optional label.
diff --git a/packages/core/src/client/webcomponents/index.ts b/packages/core/src/client/webcomponents/index.ts
index 6ae4c366..a9a577f3 100644
--- a/packages/core/src/client/webcomponents/index.ts
+++ b/packages/core/src/client/webcomponents/index.ts
@@ -17,6 +17,8 @@ export type {
KeyValueTableProps,
ProgressProps,
SwitchProps,
+ TabDescriptor,
+ TabsProps,
TextInputProps,
TextProps,
TreeProps,
diff --git a/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts b/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts
index 8e6a4eb7..fcbf52d8 100644
--- a/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts
+++ b/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts
@@ -33,7 +33,7 @@ const meta = {
parameters: {
docs: {
description: {
- component: 'The json-render primitive registry (`Stack`, `Card`, `Text`, `Badge`, `Button`, `Icon`, `Divider`, `Switch`, `KeyValueTable`, `DataTable`, `CodeBlock`, `Progress`) rendered from a declarative spec — the same renderer plugins use to build panels without shipping Vue.',
+ component: 'The json-render primitive registry (`Stack`, `Card`, `Tabs`, `Text`, `Badge`, `Button`, `Icon`, `Divider`, `Switch`, `KeyValueTable`, `DataTable`, `CodeBlock`, `Progress`) rendered from a declarative spec — the same renderer plugins use to build panels without shipping Vue.',
},
},
},
@@ -116,6 +116,62 @@ export const Card: StoryObj> = {
} as unknown as Spec)),
}
+/**
+ * `Tabs` switches which of its `children` renders, positionally matched to
+ * `tabs[]` — no `visible` plumbing needed in the spec. Each panel here is a
+ * `Card` of `Stack` rows (the same composition the `Card` story above uses
+ * standalone), showing that a tab panel is an ordinary element tree, not a
+ * special slot. Uncontrolled (no `value` binding), so each tab click drives
+ * Tabs' own local state; toggle `orientation` below to compare the
+ * underlined horizontal bar against the left-rail vertical layout, and use
+ * arrow keys / Home / End once a tab has focus to exercise the
+ * roving-tabindex keyboard navigation.
+ */
+interface TabsArgs {
+ orientation: 'horizontal' | 'vertical'
+}
+
+export const Tabs: StoryObj> = {
+ argTypes: {
+ orientation: { control: 'select', options: ['horizontal', 'vertical'] },
+ },
+ args: { orientation: 'horizontal' },
+ render: args => renderSpec(() => ({
+ root: 'root',
+ state: {},
+ elements: {
+ root: {
+ type: 'Tabs',
+ props: {
+ orientation: args.orientation,
+ tabs: [
+ { value: 'mfe', label: 'Micro-Frontends', badge: '2', badgeVariant: 'success' },
+ { value: 'shells', label: 'Shells' },
+ { value: 'gateway', label: 'Gateway', badge: '1', badgeVariant: 'danger' },
+ ],
+ },
+ children: ['mfeCard', 'shellsCard', 'gatewayCard'],
+ },
+ mfeCard: { type: 'Card', props: { title: 'Micro-Frontends', variant: 'secondary' }, children: ['mfeRows'] },
+ mfeRows: { type: 'Stack', props: { direction: 'column', gap: 4, padding: 4 }, children: ['mfeRow1', 'mfeRow2'] },
+ mfeRow1: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['mfeRow1Text', 'mfeRow1Badge'] },
+ mfeRow1Text: { type: 'Text', props: { text: 'vite-plugin-inspect', variant: 'code' } },
+ mfeRow1Badge: { type: 'Badge', props: { text: 'enabled', variant: 'success' } },
+ mfeRow2: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['mfeRow2Text', 'mfeRow2Badge'] },
+ mfeRow2Text: { type: 'Text', props: { text: 'vite-plugin-vue', variant: 'code' } },
+ mfeRow2Badge: { type: 'Badge', props: { text: 'enabled', variant: 'success' } },
+
+ shellsCard: { type: 'Card', props: { title: 'Shells', variant: 'secondary' }, children: ['shellsBody'] },
+ shellsBody: { type: 'Text', props: { text: 'No shells running locally.', variant: 'caption' } },
+
+ gatewayCard: { type: 'Card', props: { title: 'Gateway', variant: 'secondary' }, children: ['gatewayRow'] },
+ gatewayRow: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center', padding: 4 }, children: ['gatewayRowText', 'gatewayRowBadge'] },
+ gatewayRowText: { type: 'Text', props: { text: 'gateway-web', variant: 'code' } },
+ gatewayRowBadge: { type: 'Badge', props: { text: 'stale override', variant: 'danger' } },
+ },
+ } as unknown as Spec)),
+}
+
/**
* An element whose `type` has no entry in the registry — e.g. authored
* against a newer base-catalog version than this client implements, or a
diff --git a/packages/core/src/client/webcomponents/json-render/components/Tabs.ts b/packages/core/src/client/webcomponents/json-render/components/Tabs.ts
new file mode 100644
index 00000000..f3145325
--- /dev/null
+++ b/packages/core/src/client/webcomponents/json-render/components/Tabs.ts
@@ -0,0 +1,175 @@
+import { useBoundProp } from '@json-render/vue'
+import { defineComponent, h, ref, watchEffect } from 'vue'
+import { getIconifySvg } from '../../utils/iconify'
+import { colors, primary, surfaceSubtle } from './tokens'
+import { registryProps } from './types'
+
+export interface TabDescriptor {
+ value: string
+ label: string
+ icon?: string
+ badge?: string
+ badgeVariant?: 'default' | 'info' | 'success' | 'warning' | 'danger'
+}
+
+export interface TabsProps {
+ /** `children[i]` renders when `tabs[i]` is active — the two arrays are positional. */
+ tabs: TabDescriptor[]
+ /** Two-way bindable via `{ $bindState: '...' }`; otherwise the tab switches local, uncontrolled state. */
+ value?: string
+ /** Seeds the uncontrolled case only — ignored once `value` is bound. */
+ defaultValue?: string
+ orientation?: 'horizontal' | 'vertical'
+}
+
+export const Tabs = defineComponent({
+ name: 'JrTabs',
+ props: registryProps<'Tabs', TabsProps>(),
+ setup(ctx, { slots }) {
+ /** Local fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */
+ const uncontrolledValue = ref(ctx.element.props.defaultValue ?? ctx.element.props.tabs?.[0]?.value)
+
+ /** Icon SVGs keyed by name, resolved like `Icon.ts` — one tab's icon changing shouldn't refetch the others. */
+ const iconSvgs = ref>({})
+ watchEffect(async () => {
+ const names = (ctx.element.props.tabs ?? [])
+ .map(tab => tab.icon)
+ .filter((name): name is string => !!name && !(name in iconSvgs.value))
+ for (const name of names) {
+ const match = name.match(/^(?:i-)?([\w-]+):([\w-]+)$/)
+ if (match?.[1] && match[2]) {
+ const svg = await getIconifySvg(match[1], match[2])
+ if (svg)
+ iconSvgs.value = { ...iconSvgs.value, [name]: svg }
+ }
+ }
+ })
+
+ return () => {
+ const tabs: TabDescriptor[] = ctx.element.props.tabs ?? []
+ const orientation: 'horizontal' | 'vertical' = ctx.element.props.orientation === 'vertical' ? 'vertical' : 'horizontal'
+ const isVertical = orientation === 'vertical'
+
+ const [boundValue, setBoundValue] = useBoundProp(ctx.element.props.value, ctx.bindings?.value)
+ const controlled = ctx.bindings?.value != null
+ const activeValue = controlled ? boundValue : uncontrolledValue.value
+ const change = ctx.on('change')
+ const setActive = (next: string) => {
+ if (controlled)
+ setBoundValue(next)
+ else uncontrolledValue.value = next
+ change.emit()
+ }
+
+ /** Roving tabindex per WAI-ARIA — arrow keys move focus and selection together. */
+ const move = (fromIndex: number, delta: number, container: HTMLElement) => {
+ if (tabs.length === 0)
+ return
+ const nextIndex = (fromIndex + delta + tabs.length) % tabs.length
+ const nextTab = tabs[nextIndex]!
+ setActive(nextTab.value)
+ requestAnimationFrame(() => {
+ (container.querySelectorAll('[role="tab"]')[nextIndex] as HTMLElement | undefined)?.focus()
+ })
+ }
+
+ const tabButtons = tabs.map((tab, index) => {
+ const active = tab.value === activeValue
+ return h('button', {
+ 'type': 'button',
+ 'role': 'tab',
+ 'aria-selected': active ? 'true' : 'false',
+ 'tabindex': active ? '0' : '-1',
+ 'class': 'jr-tab',
+ 'style': {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '6px',
+ padding: '6px 10px',
+ fontSize: '12px',
+ fontWeight: active ? '600' : '400',
+ color: active ? primary : 'inherit',
+ background: 'none',
+ border: 'none',
+ borderBottom: !isVertical ? `2px solid ${active ? primary : 'transparent'}` : undefined,
+ borderLeft: isVertical ? `2px solid ${active ? primary : 'transparent'}` : undefined,
+ // Rounds the corners away from the active-tab indicator: top for the horizontal underline, right for the vertical rail.
+ borderRadius: isVertical ? '0 4px 4px 0' : '4px 4px 0 0',
+ cursor: 'pointer',
+ whiteSpace: 'nowrap',
+ transition: 'background-color 0.15s ease',
+ },
+ 'onClick': () => setActive(tab.value),
+ 'onMouseenter': (e: MouseEvent) => {
+ if (!active)
+ (e.currentTarget as HTMLElement).style.backgroundColor = surfaceSubtle
+ },
+ 'onMouseleave': (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.backgroundColor = '' },
+ 'onKeydown': (e: KeyboardEvent) => {
+ const container = (e.currentTarget as HTMLElement).parentElement
+ if (!container)
+ return
+ const forward = isVertical ? 'ArrowDown' : 'ArrowRight'
+ const backward = isVertical ? 'ArrowUp' : 'ArrowLeft'
+ if (e.key === forward) {
+ e.preventDefault()
+ move(index, 1, container)
+ }
+ else if (e.key === backward) {
+ e.preventDefault()
+ move(index, -1, container)
+ }
+ else if (e.key === 'Home') {
+ e.preventDefault()
+ move(index, -index, container)
+ }
+ else if (e.key === 'End') {
+ e.preventDefault()
+ move(index, tabs.length - 1 - index, container)
+ }
+ },
+ }, [
+ tab.icon && h('span', {
+ style: { display: 'inline-flex', width: '14px', height: '14px', lineHeight: '1' },
+ innerHTML: iconSvgs.value[tab.icon] || '',
+ }),
+ h('span', tab.label),
+ tab.badge && h('span', {
+ class: `jr-badge jr-badge-${tab.badgeVariant ?? 'default'}`,
+ style: {
+ display: 'inline-block',
+ padding: '1px 6px',
+ borderRadius: '9px',
+ fontSize: '10px',
+ fontWeight: '500',
+ backgroundColor: (colors[tab.badgeVariant ?? 'default'] ?? colors.default).bg,
+ color: (colors[tab.badgeVariant ?? 'default'] ?? colors.default).fg,
+ },
+ }, tab.badge),
+ ])
+ })
+
+ const panels = slots.default?.() ?? []
+ const activeIndex = tabs.findIndex(tab => tab.value === activeValue)
+ const activePanel = activeIndex >= 0 ? panels[activeIndex] : undefined
+
+ return h('div', {
+ style: { display: 'flex', flexDirection: isVertical ? 'row' : 'column', gap: '8px' },
+ }, [
+ h('div', {
+ 'role': 'tablist',
+ 'aria-orientation': orientation,
+ 'style': {
+ display: 'flex',
+ flexDirection: isVertical ? 'column' : 'row',
+ gap: '2px',
+ borderBottom: !isVertical ? '1px solid var(--jr-border, rgba(128,128,128,0.2))' : undefined,
+ borderRight: isVertical ? '1px solid var(--jr-border, rgba(128,128,128,0.2))' : undefined,
+ flexShrink: '0',
+ },
+ }, tabButtons),
+ h('div', { role: 'tabpanel', style: { flex: '1', minWidth: '0' } }, activePanel ? [activePanel] : []),
+ ])
+ }
+ },
+})
diff --git a/packages/core/src/client/webcomponents/json-render/registry.ts b/packages/core/src/client/webcomponents/json-render/registry.ts
index bd21fa75..5aa642f0 100644
--- a/packages/core/src/client/webcomponents/json-render/registry.ts
+++ b/packages/core/src/client/webcomponents/json-render/registry.ts
@@ -10,6 +10,7 @@ import { KeyValueTable } from './components/KeyValueTable'
import { Progress } from './components/Progress'
import { Stack } from './components/Stack'
import { Switch } from './components/Switch'
+import { Tabs } from './components/Tabs'
import { Text } from './components/Text'
import { TextInput } from './components/TextInput'
import { Tree } from './components/Tree'
@@ -37,6 +38,7 @@ export type { IconProps } from './components/Icon'
export type { KeyValueTableProps } from './components/KeyValueTable'
export type { ProgressProps } from './components/Progress'
export type { SwitchProps } from './components/Switch'
+export type { TabDescriptor, TabsProps } from './components/Tabs'
export type { TextProps } from './components/Text'
export type { TextInputProps } from './components/TextInput'
export type { TreeProps } from './components/Tree'
@@ -45,6 +47,7 @@ export type { UIElement as JsonRenderElement } from '@json-render/core'
export const devtoolsRegistry: Record = {
Stack,
Card,
+ Tabs,
Text,
Badge,
Button,
diff --git a/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts b/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts
index ed75e479..3b529a9f 100644
--- a/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts
+++ b/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts
@@ -62,6 +62,19 @@ export interface SwitchProps {
label?: string;
disabled?: boolean;
}
+export interface TabDescriptor {
+ value: string;
+ label: string;
+ icon?: string;
+ badge?: string;
+ badgeVariant?: 'default' | 'info' | 'success' | 'warning' | 'danger';
+}
+export interface TabsProps {
+ tabs: TabDescriptor[];
+ value?: string;
+ defaultValue?: string;
+ orientation?: 'horizontal' | 'vertical';
+}
export interface TextInputProps {
value?: string;
placeholder?: string;