Skip to content

Announcing Winduum 3

Winduum 3 is a native-first release focused on one goal: use more of the browser and ship less JavaScript.

The web platform can now handle much of the behavior that component libraries used to recreate themselves. Dialogs can be opened with Invoker Commands, popovers can live in the top layer through the Popover API and anchor themselves with CSS Anchor Positioning, accordions can be exclusive through the native name attribute on <details>, and CSS Scroll Snap can drive carousels and drawers. Winduum 3 adopts those capabilities directly and adds only the small enhancement layer that is still useful.

The result is a leaner core, simpler markup and components that preserve useful behavior even before JavaScript loads — or when it does not load at all.

Upgrading from Winduum 2?

Start with the Winduum v3 migration guide, which covers the core and winduum-stimulus together. Once that migration works, projects replacing Stimulus with Winduum Elements can continue with the winduum-elements migration guide.

Winduum Elements, Built on Webuum

The main addition in this release is winduum-elements: a set of ready-to-use Custom Elements for Winduum components.

Winduum Elements is built on Webuum, a tiny framework for progressively enhancing server-rendered websites with native Custom Elements. Webuum has no virtual DOM, hydration layer, router or application runtime. It keeps component behavior close to the HTML and relies on browser APIs wherever the platform already has an answer.

Read the Webuum v0.x announcement for the ideas behind that foundation.

Install the next release and register only the elements your project needs:

shell
npm install winduum@next winduum-elements@next
js
import { Drawer } from 'winduum-elements/components/drawer/index.js'
import { Popover } from 'winduum-elements/components/popover/index.js'

customElements.define('x-drawer', Drawer, { extends: 'dialog' })
customElements.define('x-popover', Popover)

This gives server-rendered projects a frameworkless default integration while keeping winduum-stimulus, winduum-vue and winduum-react available for projects that already use those stacks.

The Browser Is the Component Runtime

Winduum 3 replaces custom behavior with native HTML and CSS wherever possible.

ComponentNative foundationJavaScript in Winduum 3
Dialog<dialog>, Invoker Commands, closedbyNo controller or custom open/close API; only an optional compatibility side effect
Details<details>, <summary>, name, CSS transitionsNone for standard details and accordions; an optional helper syncs the checkbox variant
PopoverPopover API, Invoker Commands, CSS Anchor Positioning, interestforNone in modern browsers; a small positioning fallback is available
Drawer<dialog>, Invoker Commands, scroll snap, scroll-driven animations, observersReduced to the enhancement layer for command handling, snap state, swipe dismissal and fallbacks
MarqueeCSS motion path, sibling-index() and animationsNone

Dialog Without a JavaScript Controller

The Dialog component no longer needs its old showDialog, closeDialog and defaultOptions APIs. A button now opens and closes the native <dialog> declaratively with Invoker Commands (command and commandfor), while closedby controls light dismiss.

html
<button class="x-button" command="show-modal" commandfor="dialogBasic">Show dialog</button>
<dialog class="x-dialog" id="dialogBasic" closedby="any">
    <form class="x-dialog-content" method="dialog">
        <div class="x-heading">Example dialog</div>
        <br>
        <div class="x-text">
            <p>You can close this dialog with Esc, clicking outside, or by form submit</p>
        </div>
        <br>
        <button class="x-button">Close dialog</button>
    </form>
</dialog>

For programmatic control, use the native HTMLDialogElement.showModal() and HTMLDialogElement.close() methods. The optional side-effect script only fills gaps for browsers without closedby support and keeps the scrollbar-width CSS property up to date. See the complete Dialog documentation.

Details and Accordions in Native HTML

The Details component no longer uses showDetails, closeDetails or their JavaScript animation options. Open and close transitions are now handled in CSS, while exclusive accordions use the native name attribute on related <details> elements.

html
<details class="x-details group bg-body-secondary rounded-md">
    <summary class="flex items-center gap-2 text-primary p-4">
        <span class="x-title">Show more</span>
        <svg class="size-4 group-open:-scale-y-100 transition-transform" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
            <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
        </svg>
    </summary>
    <div class="p-4">
        Details content
    </div>
</details>

Standard details and accordion patterns need no JavaScript. The synchronous toggleDetails helper remains only for the optional pattern that keeps a checkbox inside <summary> in sync with the open state. See the complete Details documentation.

Popover and Anchor Positioning

The Popover component has been rewritten around the native Popover API, Invoker Commands and CSS Anchor Positioning. The browser owns top-layer placement, light dismiss, Escape handling and the open state.

html
<button class="x-button" command="toggle-popover" commandfor="popoverExample">Popover</button>
<div class="x-popover bottom my-2" popover id="popoverExample">
    <div class="x-popover-content shadow dark:bg-body-secondary p-2 w-32 flex flex-col">
        <button class="x-button ghosted accent-main justify-start w-full">Item 1</button>
        <button class="x-button ghosted accent-main justify-start w-full">Item 2</button>
    </div>
</div>

The old showPopover, hidePopover, togglePopover and computePopover helpers are no longer part of the Winduum API — showPopover(), hidePopover() and togglePopover() now belong to the native platform. computePositionPopover and autoUpdatePopover remain as focused Floating UI fallbacks for browsers without CSS Anchor Positioning.

The former Tooltip component is deprecated in favor of the x-popover tooltip variant. Its directional variants now use the explicit tooltip-top, tooltip-bottom, tooltip-left and tooltip-right names. See the complete Popover documentation.

A Much Smaller Drawer Layer

The Drawer component still supports touch-driven dismissal, but the browser now does most of the work. Native Invoker Commands open and close its dialog, CSS Scroll Snap defines the open and closed positions, a scroll-driven animation drives the backdrop and an Intersection Observer tracks settled state. This follows the same platform-first principles described in the Chrome team's navigation drawer guidance: the user's finger controls the native scroller instead of a JavaScript transform tween.

html
<button class="x-button" command="show-modal" commandfor="drawerNoScriptElement">Show drawer</button>
<dialog class="x-drawer noscript:starting:-translate-x-full noscript:not-open:-translate-x-full noscript:duration-500" is="x-drawer" id="drawerNoScriptElement" closedby="any">
    <div class="x-drawer-scroller snap-x snap-mandatory noscript:after:content-none">
        <nav class="x-drawer-content" data-x-drawer-part="content">
            Drawer content
            <button class="x-button muted" command="request-close" commandfor="drawerNoScriptElement">Close drawer</button>
        </nav>
    </div>
</dialog>
vue
<script setup lang="ts">
    import { ref, useId } from 'vue'
    import { Drawer, DrawerContent, DrawerScroller } from '@/components/drawer'
    import { Button } from '@/components/button'

    const drawerId = useId()
    const contentElement = ref<HTMLElement>()
</script>

<template>
    <Button command="show-modal" :commandfor="drawerId">Show drawer</Button>
    <Drawer :id="drawerId" closedby="any" class="noscript:starting:-translate-x-full noscript:not-open:-translate-x-full noscript:duration-500" :refs="{ contentElement }">
        <DrawerScroller class="snap-x snap-mandatory noscript:after:content-none">
            <DrawerContent ref="contentElement" as="nav">
                Drawer content
                <Button class="muted" command="request-close" :commandfor="drawerId">
                    Close drawer
                </Button>
            </DrawerContent>
        </DrawerScroller>
    </Drawer>
</template>
tsx
import { useId, useRef } from 'react'
import { Drawer, DrawerContent, DrawerScroller } from '@/components/drawer'
import { Button } from '@/components/button'

export function Example() {
    const drawerId = useId()
    const contentElement = useRef<HTMLElement>(null)

    return (
        <>
            <Button command="show-modal" commandfor={drawerId}>Show drawer</Button>
            <Drawer id={drawerId} closedby="any" className="noscript:starting:-translate-x-full noscript:not-open:-translate-x-full noscript:duration-500" refs={{ contentElement }}>
                <DrawerScroller className="snap-x snap-mandatory noscript:after:content-none">
                    <DrawerContent ref={contentElement} as="nav">
                        Drawer content
                        <Button className="muted" command="request-close" commandfor={drawerId}>
                            Close drawer
                        </Button>
                    </DrawerContent>
                </DrawerScroller>
            </Drawer>
        </>
    )
}

The v3 rewrite adds placement-based helpers, modal and non-modal patterns, a responsive dialog variant and two CSS-first fallbacks:

  • noscript keeps an animated drawer usable when JavaScript is disabled.
  • nosnap removes the scroll-snap behavior and works without JavaScript.
  • dialog changes from a bottom drawer to a centered dialog at a container breakpoint.
  • left, right, top and bottom placements share the same API.

See the complete Drawer documentation, including every placement and the dialog, noscript and nosnap examples.

CSS-only Marquee

The new Marquee component creates an infinite loop with a CSS motion path, sibling-count() and sibling-index(). It has no JavaScript runtime and pauses when keyboard focus moves inside the component.

The --x-marquee-sibling-index and --x-marquee-sibling-count properties provide a declarative fallback until sibling-index() and sibling-count() are available in every target browser. See the Marquee docs for the complete HTML, Vue and React examples.

Native-first Components Beyond the Headliners

The same direction continues across the rest of the library.

The goal of Carousel Experimental was to mirror the CSS Overflow Module Level 5 carousel API in JavaScript. Scroll buttons, markers and the active snap target would use ordinary elements, making patterns such as Chrome's Horizontal List available without relying on the still-unsupported ::scroll-button() and ::scroll-marker pseudo-elements.

That compatibility layer is not ready yet because the required Scroll Snap behavior is not consistent across browsers. The scrollsnapchange event is still unavailable in Firefox and Safari, while Safari's scrollBy() can fail in combination with scroll snapping (WebKit #270064, WebKit #316084). The component therefore remains experimental; use the stable Carousel when broad browser support is required.

Integrations and Component APIs

  • winduum-vue and winduum-react have been updated for Winduum 3 and now include the previously missing components, including the interactive components.
  • winduum-stimulus v3 adopts the same Winduum 3 markup and APIs for projects that want to stay on Stimulus.
  • Text is split into the small base x-text component and the x-text content variant for rich CMS or WYSIWYG output.
  • Form now imports validateField from the standalone Field module. The default selector is .x-field, validateSelectors is renamed to validateSelector, and validation options are smaller.
  • Toast auto-hides after 7.5 seconds by default and removes its element after the exit transition. The new toasterObserver opens and closes a Toaster popover as its contents change.
  • tel-country-code adds styles for country-code select controls.
  • Typography, headings, labels, selects, the container width and responsive container padding have updated defaults.

Tailwind CSS Utilities That Match the Platform

Several utilities and variants first shipped experimentally between Winduum 2.2.20 and 2.2.28. Winduum 3 makes them part of the documented workflow. Together they answer the question posed in Do you still need Framer Motion? in a deliberately pragmatic way: start with CSS when the browser already owns the interaction or timeline, then add JavaScript only when the effect needs runtime physics, gesture data or orchestration.

The documented workflow now includes:

Expressive easing without a runtime

Winduum now includes three easing tokens beyond the familiar ease-in, ease-out and ease-in-out curves:

  • ease-anticipate briefly moves against the expected direction before accelerating forward.
  • ease-spring uses the CSS linear() easing function to encode a sampled spring-like curve.
  • ease-emphasized accelerates quickly and settles smoothly. It is also the default timing function for animation-trigger-enter.

Tailwind CSS ease-* utilities apply the curve directly to transition-timing-function and expose it through --tw-ease. Keyframe and scroll-driven animations can reuse the same token, but must connect it to animation-timing-function explicitly:

html
<button class="transition-transform duration-700 ease-spring hover:scale-110">
    Add to cart
</button>
html
<div class="transition-transform duration-500 ease-anticipate hover:-translate-y-2">
    Hover me
</div>
html
<div
    class="animation animation-[enter] from-translate-y-4 ease-emphasized
           [animation-timing-function:var(--tw-ease)]"
>
    Content
</div>

ease-spring is intentionally a predefined curve, not a physics engine. It cannot inherit the release velocity of a drag gesture, change its duration from spring parameters or preserve velocity when interrupted. Motion still makes sense for those cases; CSS is the smaller option when a consistent spring-like transition is enough.

Scroll-linked animation with animation-timeline

CSS scroll-driven animations replace the usual scroll listener and per-frame progress calculation with a browser-owned timeline. Winduum exposes both kinds through its Animation Timeline utilities:

  • animation-timeline-scroll, -x and -y use scroll() so animation progress follows a scroll container.
  • animation-timeline-view uses view() so progress follows an element as it passes through the viewport.
  • animation-range-* limits the part of that timeline in which the animation runs.
  • Named scroll-timeline-*, view-timeline-* and timeline-scope-* utilities connect a timeline to an animation elsewhere in the component.

Approaching Baseline

Scroll-linked animations are close to becoming Baseline across the major browser engines. The current scroll-timeline support table lists Firefox 155 as its first supported release, joining the existing Chromium and Safari implementations.

html
<article
    class="animation animation-[enter] from-translate-y-8
           animation-timeline-view animation-range-[entry_0%_cover_40%]"
>
    Content reveals as it enters the viewport.
</article>
html
<div class="scroll-timeline-x-[--gallery] overflow-x-auto">
    <ul class="flex w-max">...</ul>

    <div
        class="animation animation-[enter] from-scale-75 origin-left
               animation-timeline-[--gallery]"
    ></div>
</div>

This is scroll-linked motion: its progress continuously follows scroll position. For transform and opacity, the browser can keep the animation on its optimized rendering path, while the markup still exposes the underlying animation-timeline model.

Scroll-triggered animation with animation-trigger

Scroll-triggered motion is different: a normal time-based animation should start once when an element reaches a threshold. Winduum targets the emerging animation-trigger and timeline-trigger properties for that job.

The animation-trigger-enter utility selects the composable enter keyframes, while from-* and to-* classes define their opacity, translation, scale and rotation. timeline-trigger-enter supplies a view trigger with a default 20% entry threshold and a play-once action:

html
<h2
    class="animation-trigger-enter from-translate-y-4 timeline-trigger-enter
           [--tw-animation-duration:1s] [--tw-timeline-trigger-entry:30%]"
>
    Our key partners
</h2>

The default duration is 1.75s, the default easing is ease-emphasized, and the animation uses fill-mode: both. Winduum disables it for prefers-reduced-motion and print automatically.

The properties are still experimental, so the native path is progressive enhancement rather than a hard dependency. In a browser without timeline-trigger, Winduum's fallback keeps animation-trigger-enter paused until a data-enter state is present. The opt-in timelineTrigger polyfill supplies that state with IntersectionObserver; supported browsers need no observer or trigger JavaScript at all. See the complete Animation Trigger documentation.

Scroll state as a CSS variant

Scroll-state container queries let the browser expose state that previously required scroll listeners, measurements and classes toggled from JavaScript. After setting container-type: scroll-state, Winduum's Scroll State variants can react to four groups:

  • scrollable-* shows whether more content is available in a direction — useful for edge fades and navigation controls.
  • scrolled-* shows whether the container has moved away from an edge — useful for separators and scroll affordances.
  • snapped-* identifies the currently snapped item — useful for native carousels and galleries.
  • stuck-* identifies when a sticky element is actually stuck — useful for changing a header's shadow or size only after it reaches an edge.
html
<header class="sticky top-0 [container-type:scroll-state]">
    <nav class="stuck-top:shadow-md">
        Navigation
    </nav>
</header>
html
<div class="relative overflow-x-auto [container-type:scroll-state]">
    <ul class="flex w-max">...</ul>
    <span class="hidden scrollable-right:block">More content →</span>
</div>
html
<ul class="flex overflow-x-auto snap-x snap-mandatory">
    <li class="snap-center [container-type:scroll-state]">
        <img class="scale-75 transition-transform snapped-x:scale-100" src="image.jpg" alt="">
    </li>
</ul>

Unsupported browsers ignore the conditional styles, so the content and native scrolling remain usable. The variants are best used for visual enhancement rather than as the only way to expose essential state.

Interaction, positioning and layout primitives

The rest of this group follows the same rule: expose what the browser already knows instead of rebuilding it in a component script.

  • Link scopes hover and focus transitions to real interactive elements using :any-link, enabled buttons and button roles.
  • Interest turns the declarative interestfor relationship into interest-source: and interest-target: variants for tooltips and hover cards, with an optional fallback selected by supportsInterestFor.
  • Position composes CSS Anchor Positioning, position-area and fallback flipping for popovers instead of recalculating coordinates on every layout change.
  • Grid area makes named or overlapping grid areas composable, including grid-area-full for stacking content without absolute positioning.
html
<button interestfor="tooltip" class="interest-source:text-accent">
    Show tooltip
</button>

<div id="tooltip" popover="hint" class="opacity-0 interest-target:opacity-100">
    Tooltip content
</div>
html
<button popovertarget="menu" class="[anchor-name:--menu]">Open menu</button>

<div id="menu" popover class="bottom-start [position-anchor:--menu]">
    Menu content
</div>
html
<figure class="grid">
    <img class="grid-area-full" src="image.jpg" alt="">
    <figcaption class="grid-area-full self-end">Caption</figcaption>
</figure>

The deprecated --radius token now resolves to the base xs radius (0.125rem) instead of --radius-xl. Use the named, calculated radius variables in new code.

Progressive Enhancement, Including the Fallbacks

Adopting new browser APIs does not mean shipping every polyfill to every browser. Winduum 3 adds a winduum/polyfill entry point and individual support checks so fallbacks can be loaded only where they are needed.

js
import {
    supportsAnimationTimeline,
    supportsInterestFor,
    supportsScrollInitialTarget,
    supportsScrollSnapEvents,
    supportsTimelineTrigger,
} from 'winduum/supports'

if (!supportsInterestFor) {
    await import('interestfor/src/interestfor.js')
}

if (!supportsTimelineTrigger) {
    await import('winduum/src/polyfills/timelineTrigger.js')
}

Winduum Elements also exposes Webuum's feature checks for Invoker Commands and customized built-in elements:

js
import { supportsCommand, supportsIs } from 'webuum/supports'

if (!supportsCommand) {
    const { apply } = await import('invokers-polyfill/fn')
    apply()
}

if (!supportsIs()) {
    await import('@webreflection/custom-elements-builtin')
}

The remaining checks — including animation timelines, scroll initial targets, scroll-snap events, anchor positioning and anchored containers — make it possible to choose a project-specific fallback rather than paying for a global compatibility layer. See the Polyfills documentation for the recommended setup.

Migrating to Winduum 3

Winduum 3 removes and renames JavaScript APIs, changes some component markup and updates several defaults. Existing projects should treat it as a deliberate migration rather than a dependency-only upgrade.

Winduum 3 is not a rewrite of what a component library looks like. It is a rewrite of who should own the behavior. Whenever the browser can provide the semantics, focus handling, top layer, scrolling or animation, Winduum now lets the browser do it — and keeps its own JavaScript focused on progressive enhancement.

Released under the MIT License.