---
date: 2026-07-29
title: What's new in @studiometa/ui 1.9.0
description: A tour of everything in @studiometa/ui 1.9.0, now out of beta, a Dialog that deprecates Modal and Panel, a Carousel, a ViewTransition component, new Indexable / InView / Timer / ClickOutside primitives, DataScope, Shopify Fetch adapters, declarative Track analytics and a new ESLint plugin.
tags: studiometa-ui, components, progressive-enhancement, javascript, release
---

# What's new in @studiometa/ui 1.9.0

29/07/2026 in #studiometa-ui #release

[`@studiometa/ui`](https://ui.studiometa.dev) 1.9.0 is out of beta, and it is the largest release the library has had in a while. It adds three full components, a handful of headless primitives, two Shopify [`Fetch`](https://ui.studiometa.dev/components/Fetch/) adapters, a declarative analytics family and a brand-new ESLint plugin, all on top of [`@studiometa/js-toolkit`](https://js-toolkit.studiometa.dev) `3.8.0`. This note walks through the additions worth knowing about, with a few live demos along the way.

> **Availability**
>
> Everything below ships with [`@studiometa/ui`](https://ui.studiometa.dev) **v1.9.0**. If you are on 1.8 or earlier, upgrade before reaching for any of it.
>

## New components

### Dialog, and the end of Modal and Panel

[`Dialog`](https://ui.studiometa.dev/components/Dialog/) is the headline addition. It wraps the native `<dialog>` element to build accessible modal and drawer patterns: opening with [`showModal()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal) hands the focus trap, background `inert`, <kbd>Esc</kbd>-to-close and top-layer stacking to the platform, and the component adds scroll-locking and orchestration of its [`Transition`](https://ui.studiometa.dev/components/Transition/) / [`ViewTransition`](https://ui.studiometa.dev/components/ViewTransition/) children on top.

```twig
<!-- demo-dialog.twig -->
<div class="flex flex-col items-start gap-6">
  <button
    type="button"
    data-component="Action"
    data-on:click="Dialog(#modal)->target.open()"
    class="px-4 py-2 rounded bg-blue-600 text-white font-semibold">
    Open dialog
  </button>

  <p class="max-w-md text-current/60">
    The dialog opens with <code>showModal()</code>, so the platform handles the focus trap,
    background <code>inert</code>, <kbd>Esc</kbd> and top-layer stacking. The scroll locks while it is
    open.
  </p>
</div>

{# The <dialog> host carries NO display utility, so its native open/closed
   display (block / none) is preserved, a closed dialog must stay
   display:none or it covers the page and swallows every click. #}
<dialog
  id="modal"
  data-component="Action Dialog"
  data-on:cancel.prevent="Dialog.close()"
  data-on:click="event.target === $el && Dialog.close()"
  class="fixed inset-0 m-0 p-0 w-full h-full max-w-none max-h-none bg-transparent overflow-y-auto">
  {# Fading backdrop, its own Transition child, dismisses on click. #}
  <div
    data-component="Action Transition"
    data-on:click="Dialog(#modal)->target.close()"
    data-option-enter-active="transition duration-300 ease-out"
    data-option-enter-from="opacity-0"
    data-option-leave-active="transition duration-200 ease-in"
    data-option-leave-to="opacity-0"
    data-option-leave-keep
    class="fixed inset-0 bg-black/50 opacity-0"></div>

  {# Centering layer, pointer-events-none so clicks fall through to the backdrop. #}
  <div class="pointer-events-none relative flex min-h-full items-center justify-center p-4">
    {# Scaling + fading box, a second Transition child. #}
    <div
      data-component="Transition"
      data-option-enter-active="transition duration-300 ease-out"
      data-option-enter-from="opacity-0 scale-95"
      data-option-leave-active="transition duration-200 ease-in"
      data-option-leave-to="opacity-0 scale-95"
      data-option-leave-keep
      class="pointer-events-auto relative w-full max-w-md p-8 rounded-lg bg-white text-black shadow-2xl opacity-0 scale-95">
      <h2 class="text-xl font-bold mb-3">Modal dialog</h2>
      <p class="mb-6 text-black/60">
        The backdrop is one <code>Transition</code> child (it fades) and the box another (it scales and
        fades). One <code>Dialog</code> orchestrates both.
      </p>
      <div class="flex items-center gap-4">
        <a href="https://www.studiometa.fr" class="underline text-blue-600">A focusable link</a>
        <button
          type="button"
          data-component="Action"
          data-on:click="Dialog(#modal)->target.close()"
          class="ml-auto px-4 py-2 rounded bg-gray-900 text-white">
          Close
        </button>
      </div>
    </div>
  </div>
</dialog>

<style>
  /* The native ::backdrop cannot be class-transitioned, so we hide it and animate our own backdrop
  element instead. */ dialog::backdrop { background: transparent; }
</style>
```

```ts
// demo.ts
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, Carousel, Dialog, Transition, ViewTransition } from '@studiometa/ui';

// Every demo on the page shares this one registration; each snippet only uses
// the components it needs. Carousel pulls in its own CarouselWrapper /
// CarouselItem / CarouselBtn / CarouselDrag children on its own.
registerComponents(Action, Dialog, Transition, ViewTransition, Carousel);
```

The markup stays declarative: an [`Action`](https://ui.studiometa.dev/components/Action/) button opens the dialog, the backdrop is one `Transition` child that fades, the box is another that scales, and a single `Dialog` drives both.

```html
<button type="button" data-component="Action" data-on:click="Dialog(#modal)->target.open()">
  Open dialog
</button>

<dialog
  id="modal"
  data-component="Action Dialog"
  data-on:cancel.prevent="Dialog.close()"
  data-on:click="event.target === $el && Dialog.close()">
  <!-- a Transition backdrop and a Transition box -->
</dialog>
```

A [drawer](https://ui.studiometa.dev/components/Dialog/examples#drawer) is the same component with the panel anchored to an edge and a `ViewTransition` child for the slide. Because `Dialog` covers both cases, [`Modal`](https://ui.studiometa.dev/components/Modal/) and [`Panel`](https://ui.studiometa.dev/components/Panel/) are now **deprecated**. They still work in 1.9.0, but plan on migrating to `Dialog`.

### Carousel

[`Carousel`](https://ui.studiometa.dev/components/Carousel/) is a component family built on the new [`Indexable`](https://ui.studiometa.dev/components/Indexable/) primitive: `CarouselWrapper`, `CarouselItem`, `CarouselBtn` and an optional `CarouselDrag`. It uses native CSS scroll-snap on touch devices and pointer drag (via `CarouselDrag`, mounted only on `pointer: fine` devices) elsewhere, so it stays light and accessible without hijacking the scroll.

```twig
<!-- demo-carousel.twig -->
<div data-component="Carousel" class="grid gap-8" data-option-axis="x">
  <div
    data-component="CarouselWrapper CarouselDrag"
    class="flex items-center gap-6 w-full overflow-x-auto snap-x snap-mandatory"
    style="scrollbar-width: none;">
    <div
      data-component="CarouselItem"
      class="snap-center shrink-0 flex items-center justify-center w-1/3 h-40 bg-red-400 ring-inset ring-red-600 ring text-white font-bold rounded-xl"
      style="--tw-ring-opacity: var(--carousel-item-active, 0)">
      N°1
    </div>
    <div
      data-component="CarouselItem"
      class="snap-center shrink-0 flex items-center justify-center w-1/3 h-40 bg-green-400 ring-inset ring-green-600 ring text-white font-bold rounded-xl"
      style="--tw-ring-opacity: var(--carousel-item-active, 0)">
      N°2
    </div>
    <div
      data-component="CarouselItem"
      class="snap-center shrink-0 flex items-center justify-center w-1/3 h-40 bg-blue-400 ring-inset ring-blue-600 ring text-white font-bold rounded-xl"
      style="--tw-ring-opacity: var(--carousel-item-active, 0)">
      N°3
    </div>
    <div
      data-component="CarouselItem"
      class="snap-center shrink-0 flex items-center justify-center w-1/3 h-40 bg-purple-400 ring-inset ring-purple-600 ring text-white font-bold rounded-xl"
      style="--tw-ring-opacity: var(--carousel-item-active, 0)">
      N°4
    </div>
    <div
      data-component="CarouselItem"
      class="snap-center shrink-0 flex items-center justify-center w-1/3 h-40 bg-red-400 ring-inset ring-red-600 ring text-white font-bold rounded-xl"
      style="--tw-ring-opacity: var(--carousel-item-active, 0)">
      N°5
    </div>
  </div>

  <div class="relative w-full h-2 rounded-full bg-gray-200 overflow-hidden">
    <div
      style="--tw-translate-x: calc((var(--carousel-progress, 0) - 1) * 100%)"
      class="absolute inset-0 -translate-x-full bg-black rounded-full"></div>
  </div>

  <nav class="flex items-center justify-center gap-2">
    <button
      type="button"
      data-component="CarouselBtn"
      data-option-action="prev"
      class="px-3 py-2 rounded bg-gray-900 text-white disabled:opacity-30">
      ← Prev
    </button>
    <button
      type="button"
      data-component="CarouselBtn"
      data-option-action="0"
      class="w-9 h-9 rounded bg-gray-200 text-gray-900 font-semibold">
      1
    </button>
    <button
      type="button"
      data-component="CarouselBtn"
      data-option-action="1"
      class="w-9 h-9 rounded bg-gray-200 text-gray-900 font-semibold">
      2
    </button>
    <button
      type="button"
      data-component="CarouselBtn"
      data-option-action="2"
      class="w-9 h-9 rounded bg-gray-200 text-gray-900 font-semibold">
      3
    </button>
    <button
      type="button"
      data-component="CarouselBtn"
      data-option-action="3"
      class="w-9 h-9 rounded bg-gray-200 text-gray-900 font-semibold">
      4
    </button>
    <button
      type="button"
      data-component="CarouselBtn"
      data-option-action="4"
      class="w-9 h-9 rounded bg-gray-200 text-gray-900 font-semibold">
      5
    </button>
    <button
      type="button"
      data-component="CarouselBtn"
      data-option-action="next"
      class="px-3 py-2 rounded bg-gray-900 text-white disabled:opacity-30">
      Next →
    </button>
  </nav>
</div>
```

```ts
// demo.ts
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, Carousel, Dialog, Transition, ViewTransition } from '@studiometa/ui';

// Every demo on the page shares this one registration; each snippet only uses
// the components it needs. Carousel pulls in its own CarouselWrapper /
// CarouselItem / CarouselBtn / CarouselDrag children on its own.
registerComponents(Action, Dialog, Transition, ViewTransition, Carousel);
```

Boundaries are configurable through the `boundary` option it inherits from `Indexable`: `clamp` (the default) stops at the ends and disables the `prev`/`next` controls there, while `loop` and `bounce` keep going. A `--carousel-progress` custom property on the root and a `--carousel-item-active` one on each item let you drive progress bars and active-slide styling from CSS alone, which is what the progress bar and the active ring in the demo above are wired to.

### ViewTransition

[`ViewTransition`](https://ui.studiometa.dev/components/ViewTransition/) brings the [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) to the same `enter()` / `leave()` / `toggle()` interface as [`Transition`](https://ui.studiometa.dev/components/Transition/), so [`Action`](https://ui.studiometa.dev/components/Action/) can drive it declaratively. Every `enter`/`leave` call made in the same microtask is coalesced into a single `document.startViewTransition()`, which is what lets several elements animate together as one coordinated transition. When the API is unavailable, the state change just applies instantly.

```twig
<!-- demo-viewtransition.twig -->
<div class="flex flex-col items-start gap-6">
  <div class="flex items-center gap-3">
    <button
      type="button"
      data-component="Action"
      data-option-target="ViewTransition"
      data-option-effect="target.enter()"
      class="px-4 py-2 rounded bg-blue-600 text-white font-semibold">
      Enter
    </button>
    <button
      type="button"
      data-component="Action"
      data-option-target="ViewTransition"
      data-option-effect="target.leave()"
      class="px-4 py-2 rounded bg-blue-600 text-white font-semibold">
      Leave
    </button>
    <button
      type="button"
      data-component="Action"
      data-option-target="ViewTransition"
      data-option-effect="target.toggle()"
      class="px-4 py-2 rounded bg-gray-200 text-gray-900 font-semibold">
      Toggle
    </button>
  </div>

  <div
    data-component="ViewTransition"
    data-option-view-transition-name="box"
    data-option-leave-to="hidden"
    class="max-w-md rounded bg-blue-500 p-6 text-white">
    A single element toggled through a native view transition. The enter and leave animations live
    entirely in the <code>::view-transition-*</code> CSS below; without the View Transitions API it just
    toggles instantly.
  </div>
</div>

{# The enter/leave animation is authored with the ::view-transition-* pseudo-elements. #}
<style>
  @keyframes view-transition-box-enter { from { opacity: 0; transform: translateY(1rem); } }
  @keyframes view-transition-box-leave { to { opacity: 0; transform: translateY(1rem); } }
  ::view-transition-new(box) { animation: 300ms ease-out both view-transition-box-enter; }
  ::view-transition-old(box) { animation: 300ms ease-in both view-transition-box-leave; }
</style>
```

```ts
// demo.ts
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, Carousel, Dialog, Transition, ViewTransition } from '@studiometa/ui';

// Every demo on the page shares this one registration; each snippet only uses
// the components it needs. Carousel pulls in its own CarouselWrapper /
// CarouselItem / CarouselBtn / CarouselDrag children on its own.
registerComponents(Action, Dialog, Transition, ViewTransition, Carousel);
```

The animation itself lives entirely in the `::view-transition-old()` / `::view-transition-new()` CSS. The component only names the snapshot and toggles the state.

## New primitives

1.9.0 leans hard into small, headless building blocks you compose yourself.

### Indexable

[`Indexable`](https://ui.studiometa.dev/components/Indexable/), with the `withIndex` decorator, gives you index navigation through `goTo()`, `goNext()`, `goPrev()`, a `boundary` option and an `index` event. It powers `Carousel`, and a `total` option (`data-option-total="3"`) lets you use it straight from HTML without subclassing.

### InView and InViewOnce

[`InView`](https://ui.studiometa.dev/components/InView/) and [`InViewOnce`](https://ui.studiometa.dev/components/InViewOnce/) emit directional `in-view` / `out-of-view` events as an element crosses the viewport, the `Once` variant firing a single time. Mount it alongside [`Action`](https://ui.studiometa.dev/components/Action/) and a reveal-on-scroll is a one-liner, no custom class required:

```html
<div
  data-component="Action InView"
  data-on:in-view="$el.classList.add('is-visible')"
  data-on:out-of-view="$el.classList.remove('is-visible')">
  …
</div>
```

### Timer and TimerProgress

[`Timer`](https://ui.studiometa.dev/components/Timer/) and `TimerProgress` are a headless countdown emitting bubbling lifecycle events, with a `requestAnimationFrame`-driven progress signal for smooth bars and dials. Give it a delay in seconds and react to `timer-end` from an `Action` on the same element:

```html
<div
  data-component="Action Timer"
  data-option-delay="5"
  data-on:timer-end="$el.classList.add('is-done')">
  …
</div>
```

### ClickOutside

[`ClickOutside`](https://ui.studiometa.dev/components/ClickOutside/) dispatches a `click-outside` event when a click lands outside its element, pairing with [`Action`](https://ui.studiometa.dev/components/Action/) to dismiss menus and popovers declaratively. Put both on the panel and keep its trigger inside, so the click that opens it never counts as an outside click:

```html
<div data-component="ClickOutside Action" data-on:click-outside="$el.classList.remove('is-open')">
  …
</div>
```

### DataScope

[`DataScope`](https://ui.studiometa.dev/components/DataScope/) is a local boundary for the reactive [Data](https://ui.studiometa.dev/components/DataBind/) components, so you can build patterns like tabs and scoped forms from primitives and drop two of them on a page without collisions. I covered it on its own in [Scoped, reusable UI patterns with DataScope](/notes/scoped-ui-patterns-with-datascope).

## Talking to the server: Fetch

[`Fetch`](https://ui.studiometa.dev/components/Fetch/) grew a `src` option and a no-argument `fetch()` call, so you can point a request at an explicit URL instead of relying on a form's `action` or a link's `href`. When both are set, the new `src` option takes precedence.

On top of that, two Shopify adapters land for the [Section Rendering API](https://shopify.dev/docs/api/ajax/section-rendering):

- [`FetchShopifyPartial`](https://ui.studiometa.dev/components/FetchShopifyPartial/): request one or more sections, with its `partials` option taking a comma-separated string (e.g. `data-option-partials="cart-drawer,cart-icon"`).
- [`FetchShopifySection`](https://ui.studiometa.dev/components/FetchShopifySection/): an adapter for the stable Section Rendering endpoint.

## Declarative analytics: Track

The new [`Track`](https://ui.studiometa.dev/components/Track/) family covers analytics tracking without hand-written listeners: `Track` fires events, `TrackShopify` and a `dataLayer`/GTM provider ship out of the box, and `TrackContext` lets ancestors contribute context that is merged into every event fired by their descendants.

## Tooling and under the hood

- **`@studiometa/eslint-plugin-ui`**: a new package of ESLint / Oxlint rules that help you discover and correctly use components from the library.
- **Reactivity**: the Data components were refactored onto signals with globally-shared channels, and `DataScope` resolves scoped groups through `withGroup`'s new `getScope` / `getGroup` options.
- **Dependencies**: the library now builds on [`@studiometa/js-toolkit`](https://js-toolkit.studiometa.dev) `3.8.0`.
- **Fixes**: `Slider`'s `SliderBtn` / `SliderCount` / `SliderDots` / `SliderProgress` no longer crash when mounted before their parent, `Figure` / `FigureVideo` stop hanging forever when an image or poster fails to load, and `Accordion` no longer relies on the deprecated `$parent` accessor.

## Links

- [`@studiometa/ui` documentation](https://ui.studiometa.dev) and its [changelog](https://github.com/studiometa/ui/blob/main/CHANGELOG.md)
- New components: [`Dialog`](https://ui.studiometa.dev/components/Dialog/), [`Carousel`](https://ui.studiometa.dev/components/Carousel/), [`ViewTransition`](https://ui.studiometa.dev/components/ViewTransition/)
- New primitives: [`Indexable`](https://ui.studiometa.dev/components/Indexable/), [`InView`](https://ui.studiometa.dev/components/InView/), [`Timer`](https://ui.studiometa.dev/components/Timer/), [`ClickOutside`](https://ui.studiometa.dev/components/ClickOutside/), [`DataScope`](https://ui.studiometa.dev/components/DataScope/)
- Data & Shopify: [`Fetch`](https://ui.studiometa.dev/components/Fetch/), [`FetchShopifyPartial`](https://ui.studiometa.dev/components/FetchShopifyPartial/), [`FetchShopifySection`](https://ui.studiometa.dev/components/FetchShopifySection/), [`Track`](https://ui.studiometa.dev/components/Track/)
