---
date: 2026-07-27
title: Scoped, reusable UI patterns with DataScope
description: The new DataScope component from @studiometa/ui gives Data groups a local boundary, so you can build interactive patterns like tabs and scoped forms from primitives and drop two of them on a page without them colliding.
tags: studiometa-ui, data-components, progressive-enhancement, javascript
---

# Scoped, reusable UI patterns with DataScope

27/07/2026 in #studiometa-ui #data-components

The [Data components](https://ui.studiometa.dev/components/DataBind/) from [`@studiometa/ui`](https://ui.studiometa.dev) ([`DataModel`](https://ui.studiometa.dev/components/DataModel/), [`DataBind`](https://ui.studiometa.dev/components/DataBind/), [`DataComputed`](https://ui.studiometa.dev/components/DataComputed/) and [`DataEffect`](https://ui.studiometa.dev/components/DataEffect/)) are built on the [`withGroup`](https://js-toolkit.studiometa.dev/api/decorators/withGroup.html) decorator from [`@studiometa/js-toolkit`](https://js-toolkit.studiometa.dev). `withGroup` collects every instance that shares a `data-option-group` into a set each member can reach through its `$group` getter, and the Data components use it to keep a value in sync across that group. The catch is that the group is resolved app-wide, so two UI patterns that happen to use the same group name land in the same set and share one value, so a second copy of a component on the page fights the first.

[`DataScope`](https://ui.studiometa.dev/components/DataScope/) fixes that by using `withGroup`'s [scoping feature](https://js-toolkit.studiometa.dev/api/decorators/withGroup.html#scoping-groups-to-a-parent-component). Wrapping a subtree in a `DataScope` marks it as a scope, so descendant Data components resolve their group against that scope instead of app-wide: two scopes can reuse the same group name and never share values, and each scope's keyed values live in a frozen `$data` snapshot. The upshot is that you can assemble a reusable interactive UI pattern out of the primitives and drop two of them side by side without a collision.

> **Availability**
>
> `DataScope` ships with [`@studiometa/ui`](https://ui.studiometa.dev) **v1.9.0** and up, make sure you are on that release or newer before reaching for it.
>

## Tabs without a Tabs component

A tab interface comes together from the primitives. A hidden `DataModel` holds the `active` tab as the single source of truth, seeded to `overview` with `data-option-immediate`; each tab button sets it on click and reads it back to show its selected state, and every panel is a `DataBind` that hides itself unless it is the active one. No `Tabs` class is registered, the behaviour is entirely declarative.

But put two of these on a page and, without a scope, they share the app-wide `tabs` group: switch a tab in one set and the other jumps with it.

```twig
<!-- demo-tabs-unscoped.twig -->
<div class="flex flex-wrap gap-10">
  <!-- No DataScope: both sets share the global "tabs" group, so they switch together. -->
  <div class="min-w-[16rem]">
    <div role="tablist" aria-label="Product details" class="flex gap-1 border-b border-current/20">
      <button
        type="button"
        role="tab"
        id="tab-ua-overview"
        aria-controls="panel-ua-overview"
        data-component="Action DataBind"
        data-option-group="tabs"
        data-on:click="DataModel(#utabs-a) -> target.set('overview')"
        data-bind:class.font-bold="value === 'overview'"
        data-bind:attr.aria-selected="String(value === 'overview')"
        class="px-3 py-2">
        Overview
      </button>
      <button
        type="button"
        role="tab"
        id="tab-ua-specs"
        aria-controls="panel-ua-specs"
        data-component="Action DataBind"
        data-option-group="tabs"
        data-on:click="DataModel(#utabs-a) -> target.set('specs')"
        data-bind:class.font-bold="value === 'specs'"
        data-bind:attr.aria-selected="String(value === 'specs')"
        class="px-3 py-2">
        Specs
      </button>
      <button
        type="button"
        role="tab"
        id="tab-ua-reviews"
        aria-controls="panel-ua-reviews"
        data-component="Action DataBind"
        data-option-group="tabs"
        data-on:click="DataModel(#utabs-a) -> target.set('reviews')"
        data-bind:class.font-bold="value === 'reviews'"
        data-bind:attr.aria-selected="String(value === 'reviews')"
        class="px-3 py-2">
        Reviews
      </button>
    </div>
    <input
      id="utabs-a"
      type="hidden"
      value="overview"
      data-component="DataModel"
      data-option-group="tabs"
      data-option-immediate />
    <div
      role="tabpanel"
      id="panel-ua-overview"
      aria-labelledby="tab-ua-overview"
      tabindex="0"
      data-component="DataBind"
      data-option-group="tabs"
      data-bind:attr.hidden="value !== 'overview'"
      class="py-3">
      A quick overview of the product.
    </div>
    <div
      role="tabpanel"
      id="panel-ua-specs"
      aria-labelledby="tab-ua-specs"
      tabindex="0"
      data-component="DataBind"
      data-option-group="tabs"
      data-bind:attr.hidden="value !== 'specs'"
      class="py-3"
      hidden>
      The technical specifications.
    </div>
    <div
      role="tabpanel"
      id="panel-ua-reviews"
      aria-labelledby="tab-ua-reviews"
      tabindex="0"
      data-component="DataBind"
      data-option-group="tabs"
      data-bind:attr.hidden="value !== 'reviews'"
      class="py-3"
      hidden>
      What customers are saying.
    </div>
  </div>

  <div class="min-w-[16rem]">
    <div role="tablist" aria-label="Product details" class="flex gap-1 border-b border-current/20">
      <button
        type="button"
        role="tab"
        id="tab-ub-overview"
        aria-controls="panel-ub-overview"
        data-component="Action DataBind"
        data-option-group="tabs"
        data-on:click="DataModel(#utabs-b) -> target.set('overview')"
        data-bind:class.font-bold="value === 'overview'"
        data-bind:attr.aria-selected="String(value === 'overview')"
        class="px-3 py-2">
        Overview
      </button>
      <button
        type="button"
        role="tab"
        id="tab-ub-specs"
        aria-controls="panel-ub-specs"
        data-component="Action DataBind"
        data-option-group="tabs"
        data-on:click="DataModel(#utabs-b) -> target.set('specs')"
        data-bind:class.font-bold="value === 'specs'"
        data-bind:attr.aria-selected="String(value === 'specs')"
        class="px-3 py-2">
        Specs
      </button>
      <button
        type="button"
        role="tab"
        id="tab-ub-reviews"
        aria-controls="panel-ub-reviews"
        data-component="Action DataBind"
        data-option-group="tabs"
        data-on:click="DataModel(#utabs-b) -> target.set('reviews')"
        data-bind:class.font-bold="value === 'reviews'"
        data-bind:attr.aria-selected="String(value === 'reviews')"
        class="px-3 py-2">
        Reviews
      </button>
    </div>
    <input
      id="utabs-b"
      type="hidden"
      value="overview"
      data-component="DataModel"
      data-option-group="tabs"
      data-option-immediate />
    <div
      role="tabpanel"
      id="panel-ub-overview"
      aria-labelledby="tab-ub-overview"
      tabindex="0"
      data-component="DataBind"
      data-option-group="tabs"
      data-bind:attr.hidden="value !== 'overview'"
      class="py-3">
      A quick overview of the product.
    </div>
    <div
      role="tabpanel"
      id="panel-ub-specs"
      aria-labelledby="tab-ub-specs"
      tabindex="0"
      data-component="DataBind"
      data-option-group="tabs"
      data-bind:attr.hidden="value !== 'specs'"
      class="py-3"
      hidden>
      The technical specifications.
    </div>
    <div
      role="tabpanel"
      id="panel-ub-reviews"
      aria-labelledby="tab-ub-reviews"
      tabindex="0"
      data-component="DataBind"
      data-option-group="tabs"
      data-bind:attr.hidden="value !== 'reviews'"
      class="py-3"
      hidden>
      What customers are saying.
    </div>
  </div>
</div>
```

```ts
// demo.ts
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, DataBind, DataComputed, DataModel, DataScope } from '@studiometa/ui';

// DataScope draws the boundary; the Data primitives do the syncing.
// Registering once is enough, the demos below reuse this same set.
registerComponents(DataScope, DataModel, DataBind, DataComputed, Action);
```

Wrap each set in its own `DataScope`, reusing the same group name and `active` key, and they run independently:

```twig
<!-- demo-tabs.twig -->
<div class="flex flex-wrap gap-10">
  <!-- Two tab sets in their own DataScope: same group and key, but each keeps its own tab. -->
  <div data-component="DataScope" data-option-group="tabs" class="min-w-[16rem]">
    <div role="tablist" aria-label="Product details" class="flex gap-1 border-b border-current/20">
      <button
        type="button"
        role="tab"
        id="tab-a-overview"
        aria-controls="panel-a-overview"
        data-component="Action DataBind"
        data-option-key="active"
        data-on:click="DataModel(#tabs-a) -> target.set('overview')"
        data-bind:class.font-bold="value === 'overview'"
        data-bind:attr.aria-selected="String(value === 'overview')"
        class="px-3 py-2">
        Overview
      </button>
      <button
        type="button"
        role="tab"
        id="tab-a-specs"
        aria-controls="panel-a-specs"
        data-component="Action DataBind"
        data-option-key="active"
        data-on:click="DataModel(#tabs-a) -> target.set('specs')"
        data-bind:class.font-bold="value === 'specs'"
        data-bind:attr.aria-selected="String(value === 'specs')"
        class="px-3 py-2">
        Specs
      </button>
      <button
        type="button"
        role="tab"
        id="tab-a-reviews"
        aria-controls="panel-a-reviews"
        data-component="Action DataBind"
        data-option-key="active"
        data-on:click="DataModel(#tabs-a) -> target.set('reviews')"
        data-bind:class.font-bold="value === 'reviews'"
        data-bind:attr.aria-selected="String(value === 'reviews')"
        class="px-3 py-2">
        Reviews
      </button>
    </div>
    <input
      id="tabs-a"
      type="hidden"
      name="active"
      value="overview"
      data-component="DataModel"
      data-option-immediate />
    <div
      role="tabpanel"
      id="panel-a-overview"
      aria-labelledby="tab-a-overview"
      tabindex="0"
      data-component="DataBind"
      data-option-key="active"
      data-bind:attr.hidden="value !== 'overview'"
      class="py-3">
      A quick overview of the product.
    </div>
    <div
      role="tabpanel"
      id="panel-a-specs"
      aria-labelledby="tab-a-specs"
      tabindex="0"
      data-component="DataBind"
      data-option-key="active"
      data-bind:attr.hidden="value !== 'specs'"
      class="py-3"
      hidden>
      The technical specifications.
    </div>
    <div
      role="tabpanel"
      id="panel-a-reviews"
      aria-labelledby="tab-a-reviews"
      tabindex="0"
      data-component="DataBind"
      data-option-key="active"
      data-bind:attr.hidden="value !== 'reviews'"
      class="py-3"
      hidden>
      What customers are saying.
    </div>
  </div>

  <div data-component="DataScope" data-option-group="tabs" class="min-w-[16rem]">
    <div role="tablist" aria-label="Product details" class="flex gap-1 border-b border-current/20">
      <button
        type="button"
        role="tab"
        id="tab-b-overview"
        aria-controls="panel-b-overview"
        data-component="Action DataBind"
        data-option-key="active"
        data-on:click="DataModel(#tabs-b) -> target.set('overview')"
        data-bind:class.font-bold="value === 'overview'"
        data-bind:attr.aria-selected="String(value === 'overview')"
        class="px-3 py-2">
        Overview
      </button>
      <button
        type="button"
        role="tab"
        id="tab-b-specs"
        aria-controls="panel-b-specs"
        data-component="Action DataBind"
        data-option-key="active"
        data-on:click="DataModel(#tabs-b) -> target.set('specs')"
        data-bind:class.font-bold="value === 'specs'"
        data-bind:attr.aria-selected="String(value === 'specs')"
        class="px-3 py-2">
        Specs
      </button>
      <button
        type="button"
        role="tab"
        id="tab-b-reviews"
        aria-controls="panel-b-reviews"
        data-component="Action DataBind"
        data-option-key="active"
        data-on:click="DataModel(#tabs-b) -> target.set('reviews')"
        data-bind:class.font-bold="value === 'reviews'"
        data-bind:attr.aria-selected="String(value === 'reviews')"
        class="px-3 py-2">
        Reviews
      </button>
    </div>
    <input
      id="tabs-b"
      type="hidden"
      name="active"
      value="overview"
      data-component="DataModel"
      data-option-immediate />
    <div
      role="tabpanel"
      id="panel-b-overview"
      aria-labelledby="tab-b-overview"
      tabindex="0"
      data-component="DataBind"
      data-option-key="active"
      data-bind:attr.hidden="value !== 'overview'"
      class="py-3">
      A quick overview of the product.
    </div>
    <div
      role="tabpanel"
      id="panel-b-specs"
      aria-labelledby="tab-b-specs"
      tabindex="0"
      data-component="DataBind"
      data-option-key="active"
      data-bind:attr.hidden="value !== 'specs'"
      class="py-3"
      hidden>
      The technical specifications.
    </div>
    <div
      role="tabpanel"
      id="panel-b-reviews"
      aria-labelledby="tab-b-reviews"
      tabindex="0"
      data-component="DataBind"
      data-option-key="active"
      data-bind:attr.hidden="value !== 'reviews'"
      class="py-3"
      hidden>
      What customers are saying.
    </div>
  </div>
</div>
```

```ts
// demo.ts
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, DataBind, DataComputed, DataModel, DataScope } from '@studiometa/ui';

// DataScope draws the boundary; the Data primitives do the syncing.
// Registering once is enough, the demos below reuse this same set.
registerComponents(DataScope, DataModel, DataBind, DataComputed, Action);
```

Here is one set, condensed to the parts that matter:

```html
<div data-component="DataScope" data-option-group="tabs">
  <div role="tablist" aria-label="Product details">
    <button
      type="button"
      role="tab"
      id="tab-overview"
      aria-controls="panel-overview"
      data-component="Action DataBind"
      data-option-key="active"
      data-on:click="DataModel(#tabs) -> target.set('overview')"
      data-bind:attr.aria-selected="String(value === 'overview')">
      Overview
    </button>
    <!-- …Specs, Reviews… -->
  </div>

  <input
    id="tabs"
    type="hidden"
    name="active"
    value="overview"
    data-component="DataModel"
    data-option-immediate />

  <div
    role="tabpanel"
    id="panel-overview"
    aria-labelledby="tab-overview"
    tabindex="0"
    data-component="DataBind"
    data-option-key="active"
    data-bind:attr.hidden="value !== 'overview'">
    A quick overview of the product.
  </div>
  <!-- …one panel per tab… -->
</div>
```

Going from the colliding version to this one is two edits: wrap the set in a `DataScope`, and swap the app-wide `group` for a scoped `key`.

```diff
-<div>
+<div data-component="DataScope" data-option-group="tabs">
   <button
     data-component="Action DataBind"
-    data-option-group="tabs"
+    data-option-key="active"
     data-on:click="DataModel(#tabs) -> target.set('overview')">
     Overview
   </button>
   <!-- …the hidden model and one DataBind panel per tab… -->
 </div>
```

> **Keyboard navigation**
>
> These tabs carry the full ARIA tab and tabpanel semantics, but they keep every tab in the `Tab` sequence instead of the [WAI-ARIA Tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/)'s roving `tabindex` and `Arrow`-key navigation, which needs imperative focus management. For a production widget, add that or reach for [`@studiometa/ui`'s `Tabs`](https://ui.studiometa.dev/components/Tabs/).
>

## A scoped form and `$data`

Forms have the same collision. These two share the app-wide `person` group with no scope, so typing in one field overwrites the other and both greetings track a single value:

```twig
<!-- demo-form-unscoped.twig -->
<div class="flex flex-wrap gap-8">
  <!-- No DataScope: both forms share the global "person" group and one value. -->
  <div class="space-y-2">
    <input
      value="Grace Hopper"
      aria-label="Name"
      data-component="DataModel"
      data-option-group="person"
      class="w-56 border rounded px-2 py-1 bg-transparent" />
    <p>
      Hello,
      <span data-component="DataBind" data-option-group="person" class="font-bold">
        Grace Hopper
      </span>.
    </p>
  </div>

  <div class="space-y-2">
    <input
      value="Alan Turing"
      aria-label="Name"
      data-component="DataModel"
      data-option-group="person"
      class="w-56 border rounded px-2 py-1 bg-transparent" />
    <p>
      Hello,
      <span data-component="DataBind" data-option-group="person" class="font-bold">
        Alan Turing
      </span>.
    </p>
  </div>
</div>
```

```ts
// demo.ts
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, DataBind, DataComputed, DataModel, DataScope } from '@studiometa/ui';

// DataScope draws the boundary; the Data primitives do the syncing.
// Registering once is enough, the demos below reuse this same set.
registerComponents(DataScope, DataModel, DataBind, DataComputed, Action);
```

Give each form its own `DataScope` and they are isolated. The scope also unlocks the `$data` snapshot: every keyed value in the group is exposed to `DataComputed` (and `DataEffect`), so a greeting can read `first` and `last` together, even though both forms use the same keys:

```twig
<!-- demo-form.twig -->
<div class="flex flex-wrap gap-8">
  <!-- Two forms in their own DataScope: same group, independent, each with its own $data. -->
  <div data-component="DataScope" data-option-group="person" class="space-y-2">
    <div class="flex gap-2">
      <input
        name="first"
        value="Grace"
        aria-label="First name"
        data-component="DataModel"
        data-option-immediate
        class="w-28 border rounded px-2 py-1 bg-transparent" />
      <input
        name="last"
        value="Hopper"
        aria-label="Last name"
        data-component="DataModel"
        data-option-immediate
        class="w-28 border rounded px-2 py-1 bg-transparent" />
    </div>
    <p>
      Hello,
      <span
        data-component="DataComputed"
        data-option-compute="`${$data.first} ${$data.last}`.trim()"
        class="font-bold">
        Grace Hopper
      </span>.
    </p>
  </div>

  <div data-component="DataScope" data-option-group="person" class="space-y-2">
    <div class="flex gap-2">
      <input
        name="first"
        value="Alan"
        aria-label="First name"
        data-component="DataModel"
        data-option-immediate
        class="w-28 border rounded px-2 py-1 bg-transparent" />
      <input
        name="last"
        value="Turing"
        aria-label="Last name"
        data-component="DataModel"
        data-option-immediate
        class="w-28 border rounded px-2 py-1 bg-transparent" />
    </div>
    <p>
      Hello,
      <span
        data-component="DataComputed"
        data-option-compute="`${$data.first} ${$data.last}`.trim()"
        class="font-bold">
        Alan Turing
      </span>.
    </p>
  </div>
</div>
```

```ts
// demo.ts
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, DataBind, DataComputed, DataModel, DataScope } from '@studiometa/ui';

// DataScope draws the boundary; the Data primitives do the syncing.
// Registering once is enough, the demos below reuse this same set.
registerComponents(DataScope, DataModel, DataBind, DataComputed, Action);
```

Here is the scoped markup for one form. The two inputs take their `first` and `last` keys from their `name`, and the computed greeting reads both off `$data`:

```html
<div data-component="DataScope" data-option-group="person">
  <input
    name="first"
    value="Grace"
    aria-label="First name"
    data-component="DataModel"
    data-option-immediate />
  <input
    name="last"
    value="Hopper"
    aria-label="Last name"
    data-component="DataModel"
    data-option-immediate />

  Hello,
  <span data-component="DataComputed" data-option-compute="`${$data.first} ${$data.last}`.trim()">
    Grace Hopper
  </span>
  .
</div>
```

`$data` is read-only: its object and any array or `Date` values are frozen and cloned, so a snapshot can be passed around without a subscriber mutating shared state.

## A quantity stepper and a line total

A cart line reads one number, the quantity, from several places. The [`Action`](https://ui.studiometa.dev/components/Action/) stepper buttons call [`increment()`](https://ui.studiometa.dev/components/DataBind/) on the `qty` model, and a [`DataComputed`](https://ui.studiometa.dev/components/DataComputed/) line total multiplies that value by the unit price. Without a scope, two lines share the app-wide `line` group, so stepping one changes the other's quantity and both line totals with it:

```twig
<!-- demo-stepper-unscoped.twig -->
<div class="flex flex-wrap gap-8">
  <!-- No DataScope: both lines share the global "line" group, so they step together. -->
  <div class="space-y-2">
    <p class="font-bold">
      Canvas Cap · €25
    </p>
    <div class="flex items-center gap-2">
      <button
        type="button"
        aria-label="Decrease quantity"
        data-component="Action"
        data-on:click="DataModel(#uqty-a) -> target.increment(-1)"
        class="w-8 h-8 border rounded">
        −
      </button>
      <input
        id="uqty-a"
        type="number"
        value="1"
        min="0"
        aria-label="Quantity"
        data-component="DataModel"
        data-option-group="line"
        data-option-immediate
        class="w-14 text-center border rounded px-2 py-1 bg-transparent" />
      <button
        type="button"
        aria-label="Increase quantity"
        data-component="Action"
        data-on:click="DataModel(#uqty-a) -> target.increment()"
        class="w-8 h-8 border rounded">
        +
      </button>
    </div>
    <p>
      Line total:
      <span
        data-component="DataComputed"
        data-option-group="line"
        data-option-compute="`€${(value * 25).toFixed(2)}`"
        class="font-bold">
        €25.00
      </span>
    </p>
  </div>

  <div class="space-y-2">
    <p class="font-bold">
      Wool Tote · €18
    </p>
    <div class="flex items-center gap-2">
      <button
        type="button"
        aria-label="Decrease quantity"
        data-component="Action"
        data-on:click="DataModel(#uqty-b) -> target.increment(-1)"
        class="w-8 h-8 border rounded">
        −
      </button>
      <input
        id="uqty-b"
        type="number"
        value="1"
        min="0"
        aria-label="Quantity"
        data-component="DataModel"
        data-option-group="line"
        data-option-immediate
        class="w-14 text-center border rounded px-2 py-1 bg-transparent" />
      <button
        type="button"
        aria-label="Increase quantity"
        data-component="Action"
        data-on:click="DataModel(#uqty-b) -> target.increment()"
        class="w-8 h-8 border rounded">
        +
      </button>
    </div>
    <p>
      Line total:
      <span
        data-component="DataComputed"
        data-option-group="line"
        data-option-compute="`€${(value * 18).toFixed(2)}`"
        class="font-bold">
        €18.00
      </span>
    </p>
  </div>
</div>
```

```ts
// demo.ts
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, DataBind, DataComputed, DataModel, DataScope } from '@studiometa/ui';

// DataScope draws the boundary; the Data primitives do the syncing.
// Registering once is enough, the demos below reuse this same set.
registerComponents(DataScope, DataModel, DataBind, DataComputed, Action);
```

Give each line its own `DataScope` and they add up independently:

```twig
<!-- demo-stepper.twig -->
<div class="flex flex-wrap gap-8">
  <!-- Two cart lines in their own DataScope: each qty and line total stays independent. -->
  <div data-component="DataScope" data-option-group="line" class="space-y-2">
    <p class="font-bold">
      Canvas Cap · €25
    </p>
    <div class="flex items-center gap-2">
      <button
        type="button"
        aria-label="Decrease quantity"
        data-component="Action"
        data-on:click="DataModel(#qty-a) -> target.increment(-1)"
        class="w-8 h-8 border rounded">
        −
      </button>
      <input
        id="qty-a"
        type="number"
        name="qty"
        value="1"
        min="0"
        aria-label="Quantity"
        data-component="DataModel"
        data-option-immediate
        class="w-14 text-center border rounded px-2 py-1 bg-transparent" />
      <button
        type="button"
        aria-label="Increase quantity"
        data-component="Action"
        data-on:click="DataModel(#qty-a) -> target.increment()"
        class="w-8 h-8 border rounded">
        +
      </button>
    </div>
    <p>
      Line total:
      <span
        data-component="DataComputed"
        data-option-key="qty"
        data-option-compute="`€${(value * 25).toFixed(2)}`"
        class="font-bold">
        €25.00
      </span>
    </p>
  </div>

  <div data-component="DataScope" data-option-group="line" class="space-y-2">
    <p class="font-bold">
      Wool Tote · €18
    </p>
    <div class="flex items-center gap-2">
      <button
        type="button"
        aria-label="Decrease quantity"
        data-component="Action"
        data-on:click="DataModel(#qty-b) -> target.increment(-1)"
        class="w-8 h-8 border rounded">
        −
      </button>
      <input
        id="qty-b"
        type="number"
        name="qty"
        value="1"
        min="0"
        aria-label="Quantity"
        data-component="DataModel"
        data-option-immediate
        class="w-14 text-center border rounded px-2 py-1 bg-transparent" />
      <button
        type="button"
        aria-label="Increase quantity"
        data-component="Action"
        data-on:click="DataModel(#qty-b) -> target.increment()"
        class="w-8 h-8 border rounded">
        +
      </button>
    </div>
    <p>
      Line total:
      <span
        data-component="DataComputed"
        data-option-key="qty"
        data-option-compute="`€${(value * 18).toFixed(2)}`"
        class="font-bold">
        €18.00
      </span>
    </p>
  </div>
</div>
```

```ts
// demo.ts
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, DataBind, DataComputed, DataModel, DataScope } from '@studiometa/ui';

// DataScope draws the boundary; the Data primitives do the syncing.
// Registering once is enough, the demos below reuse this same set.
registerComponents(DataScope, DataModel, DataBind, DataComputed, Action);
```

The scoped markup, one line:

```html
<div data-component="DataScope" data-option-group="line">
  <button
    type="button"
    aria-label="Decrease quantity"
    data-component="Action"
    data-on:click="DataModel(#qty) -> target.increment(-1)">
    −
  </button>
  <input
    id="qty"
    type="number"
    name="qty"
    value="1"
    aria-label="Quantity"
    data-component="DataModel"
    data-option-immediate />
  <button
    type="button"
    aria-label="Increase quantity"
    data-component="Action"
    data-on:click="DataModel(#qty) -> target.increment()">
    +
  </button>

  Line total:
  <span
    data-component="DataComputed"
    data-option-key="qty"
    data-option-compute="`€${(value * 25).toFixed(2)}`">
    €25.00
  </span>
</div>
```

Same story as the tabs: the scope is the whole difference. Wrap the line and swap the app-wide `group` for a scoped `key` (the `<input>` takes its key from its `name`):

```diff
-<div>
+<div data-component="DataScope" data-option-group="line">
   <!-- − / + buttons and the qty <input name="qty"> -->
   <span
     data-component="DataComputed"
-    data-option-group="line"
+    data-option-key="qty"
     data-option-compute="`€${(value * 25).toFixed(2)}`">…</span>
 </div>
```

## When to skip DataScope

Not every pattern needs shared state. A dropdown or popover menu is better left to the native [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API), which handles opening, top-layer stacking and click-outside dismissal on its own. Reach for `DataScope` when several elements must stay in sync on one value and you want two of them to coexist on a page.

## Register once, use everywhere

Every demo above shares the same registration; `DataScope` only needs to wrap the markup.

```js twoslash
// @noErrors
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, DataBind, DataComputed, DataModel, DataScope } from '@studiometa/ui';

registerComponents(DataScope, DataModel, DataBind, DataComputed, Action);
```

## Links

- [`DataScope` documentation](https://ui.studiometa.dev/components/DataScope/)
- [`Action`](https://ui.studiometa.dev/components/Action/), [`DataModel`](https://ui.studiometa.dev/components/DataModel/), [`DataBind`](https://ui.studiometa.dev/components/DataBind/) and [`DataComputed`](https://ui.studiometa.dev/components/DataComputed/)
- [`@studiometa/ui`](https://ui.studiometa.dev) and [`@studiometa/js-toolkit`](https://js-toolkit.studiometa.dev)
