npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kyralabs/m3e-elements

v1.8.0

Published

Zero-dependency Material 3 Expressive custom elements.

Readme

@kyralabs/m3e-elements

Zero-dependency Material 3 Expressive custom elements, for the behavior CSS alone can't provide. Each element renders the @kyralabs/m3e-css DOM contract, so load that CSS too.

npm install @kyralabs/m3e-elements @kyralabs/m3e-css @kyralabs/m3e-tokens

Usage

// Plus the m3e-tokens stylesheets listed in the @kyralabs/m3e-css README.
import '@kyralabs/m3e-css/index.css';
import '@kyralabs/m3e-elements/icon.js';
import '@kyralabs/m3e-elements/loading-indicator.js';
<m3e-icon name="favorite" fill></m3e-icon>
<m3e-loading-indicator contained aria-label="Loading"></m3e-loading-indicator>

Each @kyralabs/m3e-elements/<element>.js entry registers one element; . registers all of them, and ./base.js exports the M3eElement base class without registering anything. Registration is skipped outside the browser.

Events are kebab-case CustomEvents with a detail; a native event of an inner control with the same name (input, change, select) does not reach the host. Each element with events exports an M3e<Name>EventMap, and its addEventListener / removeEventListener are typed from it (type-only), so document.querySelector('m3e-dialog')?.addEventListener('open-change', (e) => e.detail.open) needs no cast. Shared detail types: OpenChangeDetail, ExpandedChangeDetail, ValueDetail<T>, SelectionChangeDetail. Helper functions and constants tagged @internal are exported for m3e-react and are not part of the public API.

FAB menu

import '@kyralabs/m3e-elements/fab-menu.js' registers <m3e-fab-menu> (and its data child <m3e-fab-menu-item>): a toggle FAB that opens a menu of 2–6 actions with Compose's springs, focus handling and light dismiss. It fires open-change (detail.open) and, on the item, action (detail.value).

<m3e-fab-menu label="Share" icon="share">
  <m3e-fab-menu-item icon="mail" value="email">Email</m3e-fab-menu-item>
  <m3e-fab-menu-item icon="link" value="link">Copy link</m3e-fab-menu-item>
</m3e-fab-menu>

Menus

import '@kyralabs/m3e-elements/menu.js';
<button id="more" class="m3e-icon-button m3e-icon-button--standard" aria-label="More">
  <span class="m3e-icon" aria-hidden="true">more_vert</span>
</button>
<m3e-menu anchor="more" variant="vibrant">
  <m3e-menu-item value="edit" icon="edit" trailing-text="⌘E">Edit</m3e-menu-item>
  <m3e-menu-group label="View" divider>
    <m3e-menu-item type="checkbox" value="grid" checked-icon="check" checked>Grid</m3e-menu-item>
  </m3e-menu-group>
</m3e-menu>

<m3e-menu> renders the .m3e-menu popover from its <m3e-menu-item> / <m3e-menu-group> children, wires its anchor (a click toggles it; Enter, Space and the arrow keys open it), places it like Compose's DropdownMenuPositionProvider and springs it open and closed. It emits action (on the item, detail: { value, checked }) and open-change. variant: standard (default), vibrant, baseline. Items: type="checkbox" | "radio", checked, disabled, icon, checked-icon, supporting-text, trailing-icon, trailing-text, submenu="<menu id>". The menu-popup.js entry exports MenuPopup (placement and springs only) for other components.

Button group

import '@kyralabs/m3e-elements/button-group.js';
<m3e-button-group variant="connected" selection-mode="single" label="View">
  <m3e-button-group-item value="day" selected>Day</m3e-button-group-item>
  <m3e-button-group-item value="week">Week</m3e-button-group-item>
</m3e-button-group>

<m3e-button-group> renders the .m3e-button-group contract from its <m3e-button-group-item> children. Standard groups (default) grow a pressed button by Compose's ExpandedRatio while its neighbors shrink, on an interruptible fast spatial spring; connected groups only morph the pressed or selected button's corners. Attributes: variant (standard | connected), size (xs–xl), shape (round | square), button-variant, selection-mode (none | single | multiple), disallow-empty-selection (keeps one selected; selection-required is its deprecated name), overflow (trailing items collapse into a "More options" menu; loads <m3e-menu> on first use), overflow-label, label, disabled. Items: value, icon, selected, disabled, variant, label (icon-only). Events: selection-change on the group (detail: { value, selected, values }; the deprecated change carries the same detail) and action on the item (detail: { value }). Arrow keys, Home and End move focus (one tab stop). The button-group-layout.js entry exports ButtonGroupLayout (springs, overflow measurement and roving focus for a rendered group), which m3e-react's <ButtonGroup> uses too.

Split button

import '@kyralabs/m3e-elements/menu.js';
import '@kyralabs/m3e-elements/split-button.js';
<m3e-split-button label="Send" icon="mail" menu-label="More send options" variant="tonal">
  <m3e-menu>
    <m3e-menu-item value="schedule">Schedule send</m3e-menu-item>
    <m3e-menu-item value="draft">Save draft</m3e-menu-item>
  </m3e-menu>
</m3e-split-button>

<m3e-split-button> renders the .m3e-split-button contract (a leading and a trailing .m3e-button; load m3e-css button.css and split-button.css) and makes the trailing button the anchor of its <m3e-menu> child (or the menu whose id menu names), which then handles the press, the keys, Escape and aria-expanded. While the menu is open the trailing button's inner corners spring to full and its icon turns 180°. variant: filled (default), tonal, elevated, outlined; size: xs, s (default), m, l, xl; trailing-icon (default keyboard_arrow_down); disabled. It emits press when the leading button is activated.

Floating toolbar

import '@kyralabs/m3e-elements/floating-toolbar.js';
<m3e-floating-toolbar expanded label="Formatting" scroll-behavior="exit-always">
  <button class="m3e-icon-button" slot="leading" aria-label="Done">…</button>
  <button class="m3e-icon-button" aria-label="Bold">…</button>
  <button class="m3e-icon-button" aria-label="Italic">…</button>
  <button class="m3e-fab" slot="fab" aria-label="Add">…</button>
</m3e-floating-toolbar>

<m3e-floating-toolbar> renders the .m3e-floating-toolbar contract (role="toolbar") and moves its children into it: slot="leading" / slot="trailing" items show only while expanded, slot="fab" makes the with-FAB toolbar (collapsed, the toolbar folds into the FAB). Attributes: expanded, orientation (horizontal | vertical), color-style (standard | vibrant), label, fab-position (end | start), scroll-behavior (exit-always hides it on scroll down; collapse collapses it on scroll down and fires expanded-change), exit-direction, scroll-target (the scrolling element's id). The expand/collapse springs, hidden parts made inert, roving focus and the scroll behaviors come from the floating-toolbar-layout.js entry (FloatingToolbarLayout), which m3e-react's <FloatingToolbar> uses too. Position it with inset/margins: the exit offset uses translate.

The roving-focus.js entry exports RovingFocus, the toolbar keyboard helper (one tab stop, arrows along the orientation mirrored in RTL, Home/End, no wrapping; tabbable = false takes the widget out of the tab order) for other composite widgets such as the docked toolbar.

Docked toolbar

import '@kyralabs/m3e-elements/docked-toolbar.js';
<m3e-docked-toolbar label="Actions" arrangement="center" scroll-behavior="exit-always">
  <button class="m3e-icon-button" aria-label="Back">…</button>
  <button class="m3e-icon-button" aria-label="Add">…</button>
  <button class="m3e-icon-button" aria-label="Edit">…</button>
</m3e-docked-toolbar>

<m3e-docked-toolbar> (Compose FlexibleBottomAppBar) renders the .m3e-docked-toolbar contract (role="toolbar") and moves its children into it. Attributes: arrangement (spread default, center, space-around, space-evenly), color-style (standard | vibrant), label, scroll-behavior (exit-always hides it on scroll down and brings it back on scroll up; a partly hidden bar snaps on the fast spatial spring when scrolling stops), scroll-target (the scrolling element's id). Roving focus and the scroll behavior come from the docked-toolbar-layout.js entry (DockedToolbarLayout), which m3e-react's <DockedToolbar> and deprecated <BottomAppBar> use too. Dock it yourself (position: fixed / sticky / absolute, inset-block-end: 0): the exit offset uses translate. The deprecated legacy bottom app bar (D4) has no element; use m3e-css bottom-app-bar.css or m3e-react <BottomAppBar>.

Progress indicators

import '@kyralabs/m3e-elements/linear-progress.js';
import '@kyralabs/m3e-elements/circular-progress.js';
<m3e-linear-progress value="0.4" aria-label="Loading news article"></m3e-linear-progress>
<m3e-linear-progress shape="wavy" aria-label="Loading my episodes"></m3e-linear-progress>
<m3e-circular-progress shape="wavy" thickness="thick" value="0.7" aria-label="Downloading">
</m3e-circular-progress>

<m3e-linear-progress> and <m3e-circular-progress> render the .m3e-linear-progress / .m3e-circular-progress contracts (load m3e-css linear-progress.css / circular-progress.css). value (0–1) makes them determinate (aria-valuenow 0–100); without it they are indeterminate. shape="wavy" draws Compose's wave every frame (it holds still with prefers-reduced-motion); thickness="thick" is the 8dp sample configuration; amplitude (0–1), wavelength (px) and wave-speed (px/s) override the wave. Value changes spring on Compose's progress spring. The host's aria-label (or label) names the progress bar. Flat indicators also work without JS (markup in the m3e-css files).

Top app bar

import { attachTopAppBarScroll } from '@kyralabs/m3e-elements/top-app-bar.js';

const controller = new AbortController();
attachTopAppBarScroll(document.querySelector('.m3e-top-app-bar'), {
  behavior: 'exit-until-collapsed',
  signal: controller.signal,
});
<header class="m3e-top-app-bar m3e-top-app-bar--medium-flexible">
  <div class="m3e-top-app-bar__navigation">…menu button…</div>
  <div class="m3e-top-app-bar__headline" aria-hidden="true">
    <p class="m3e-top-app-bar__title">Inbox</p>
  </div>
  <div class="m3e-top-app-bar__actions">…icon buttons…</div>
  <div class="m3e-top-app-bar__expanded">
    <div class="m3e-top-app-bar__headline"><p class="m3e-top-app-bar__title">Inbox</p></div>
  </div>
</header>

There is no top app bar element: the bars (small, center-aligned, medium and large flexible, two-rows) are m3e-css markup (load top-app-bar.css), and the top-app-bar.js entry registers nothing. attachTopAppBarScroll(bar, { behavior, target, signal }) adds Compose's scroll behaviors: pinned (default; only the container color reacts), enter-always (the bar, or a two-rows bar's __expanded row, collapses on scroll down and returns on any scroll up) or exit-until-collapsed (it expands again when the content comes back to its start). target is the scrolling element (default: the window); aborting signal detaches it and clears what it wrote. It writes data-scroll-behavior, data-scrolled (content scrolled under the bar) and the private height offset and title fractions m3e-css reads; a partly collapsed bar snaps open or closed when scrolling stops, and opens when focus moves into a hidden part. On a two-rows bar (one with an __expanded row) the top row's headline is the collapsed title: mark it aria-hidden, as above; the function moves aria-hidden to whichever headline Compose hides. Custom two-rows heights (Compose's collapsedHeight / expandedHeight) are public custom properties on the bar or an ancestor: --m3e-comp-top-app-bar-container-collapsed-height and --m3e-comp-top-app-bar-container-expanded-height (the private --_m3e-top-app-bar-*-height still work and are deprecated since 1.8). m3e-react's <TopAppBar scrollBehavior> calls the same function.

Search

import '@kyralabs/m3e-elements/search.js';
<m3e-search variant="docked" placeholder="Search mail" label="Search mail">
  <m3e-search-result icon="history" supporting-text="Recent">Inbox</m3e-search-result>
  <m3e-search-result icon="history">Drafts</m3e-search-result>
  <button class="m3e-icon-button" aria-label="Voice search">…</button>
</m3e-search>

<m3e-search> (Compose SearchBar + SearchBarDefaults.InputField) renders the .m3e-search contract (load m3e-css search.css): a role="search" landmark around a combobox input and a listbox of its <m3e-search-result> children (value, icon, supporting-text, trailing-icon, disabled); other children go to the trailing slot. variant picks the expanded view: full-screen (default, ExpandedFullScreenSearchBar), full-screen-contained, docked or docked-with-gap. A click on the field, typing or ArrowDown expands it (the view moves into the top layer on the variant's springs); ArrowDown / ArrowUp move the active result (aria-activedescendant), Enter selects it or searches, Escape, the back button or a light dismiss collapse it. Full-screen views trap Tab. Events: expanded-change, search and select (cancelable). The behavior lives in the search-layout.js entry (SearchLayout), which m3e-react's <SearchBar> uses too. AppBarWithSearch is markup: put the search between the navigation and actions of a .m3e-top-app-bar.m3e-top-app-bar--search and call attachTopAppBarScroll(bar, { behavior: 'enter-always' }) for its scroll behavior.

Window size classes

import { observeWindowSizeClass, paneScaffoldDirective } from '@kyralabs/m3e-elements';

const controller = new AbortController();
observeWindowSizeClass(
  (sizeClass) => {
    // { widthClass: 'compact' | 'medium' | 'expanded' | 'large' | 'extra-large',
    //   heightClass: 'compact' | 'medium' | 'expanded', minWidth, minHeight }
    document.body.dataset.panes = String(paneScaffoldDirective(sizeClass).maxHorizontalPartitions);
  },
  { signal: controller.signal },
);

observeWindowSizeClass(callback, { signal, target?, largeAndExtraLargeWidth? }) calls back right away and then whenever the width or height class changes (FOUNDATIONS §9; androidx WindowSizeClass, Compose currentWindowAdaptiveInfoV2()). Without target it follows the viewport through one matchMedia('(min-width: …px)') query per breakpoint, so it classifies exactly like an @media rule and wakes only when a breakpoint is crossed; with target it follows that element's content box through a ResizeObserver, like an @container query. currentWindowSizeClass(options?) reads the class once. Both do nothing (null) without a DOM. The entry (@kyralabs/m3e-elements/window-size-class.js, registers nothing) also re-exports the m3e-motion classification: windowSizeClass, windowWidthSizeClass, windowHeightSizeClass, isWidthAtLeastBreakpoint, isHeightAtLeastBreakpoint, isAtLeastBreakpoint, paneScaffoldDirective (Compose calculatePaneScaffoldDirective), navigationSuiteType (NavigationSuiteScaffoldDefaults.navigationSuiteType) and WINDOW_WIDTH_BREAKPOINTS / WINDOW_HEIGHT_BREAKPOINTS. For CSS queries use the px values in the m3e-tokens README.

Navigation bar and rails

import '@kyralabs/m3e-elements/short-navigation-bar.js';
import '@kyralabs/m3e-elements/wide-navigation-rail.js';
import '@kyralabs/m3e-elements/modal-wide-navigation-rail.js';
<m3e-wide-navigation-rail label="Main" selected="inbox">
  <button class="m3e-icon-button" aria-label="Expand" onclick="this.closest('m3e-wide-navigation-rail').toggle()">…</button>
  <m3e-navigation-item value="inbox" icon="inbox" href="/inbox">Inbox</m3e-navigation-item>
  <m3e-navigation-item value="sent" icon="send" href="/sent">Sent</m3e-navigation-item>
</m3e-wide-navigation-rail>

<m3e-short-navigation-bar> (Compose ShortNavigationBar; layout="horizontal"), <m3e-wide-navigation-rail> (WideNavigationRail) and <m3e-modal-wide-navigation-rail> (ModalWideNavigationRail) render the m3e-css .m3e-short-navigation-bar / .m3e-wide-navigation-rail / .m3e-modal-wide-navigation-rail contracts (load short-navigation-bar.css / wide-navigation-rail.css) from their <m3e-navigation-item> children (value, icon, href for a link, disabled, label-hidden); other rail children go to the header. selected marks one item aria-current="page"; a press fires a cancelable selection-change. On the rails, expanded (or expand() / collapse() / toggle()) animates width, items and labels on Compose's springs, one layout per frame and interruptible. The modal rail opens over a scrim as a dialog (focus trapped, Escape or the scrim collapse it with a cancelable expanded-change, focus returns to the opener); hide-on-collapse slides it in from the edge instead of expanding a collapsed rail. The behavior lives in the wide-navigation-rail-layout.js entry (WideNavigationRailLayout), which m3e-react's <WideNavigationRail> / <ModalWideNavigationRail> use too. The deprecated legacy bar and rail (D4) get no elements.

Navigation drawer

import '@kyralabs/m3e-elements/navigation-drawer.js';
<m3e-navigation-drawer variant="modal" label="Main" selected="inbox">
  <h2 class="m3e-navigation-drawer__headline">Mail</h2>
  <m3e-navigation-item value="inbox" icon="inbox" href="/inbox" badge="24">Inbox</m3e-navigation-item>
  <m3e-navigation-item value="sent" icon="send" href="/sent">Sent</m3e-navigation-item>
  <hr class="m3e-navigation-drawer__divider" />
  <m3e-navigation-item value="trash" icon="delete" href="/trash">Trash</m3e-navigation-item>
</m3e-navigation-drawer>

<m3e-navigation-drawer> (Compose PermanentNavigationDrawer / DismissibleNavigationDrawer / ModalNavigationDrawer) renders the .m3e-navigation-drawer contract (load navigation-drawer.css): a <nav> sheet of .m3e-navigation-drawer-items from its <m3e-navigation-item> children (value, optional icon, href, badge); other children move into the sheet in their order. variant: permanent (default; place it in a flex row before the content), dismissible (in the layout, shown with open; the content beside it moves) or modal (opens over a scrim as a dialog: focus moves in and is trapped, Escape or the scrim close it, focus returns to the opener). open (or show() / close() / toggle()) animates on Compose's springs; a drag on the open sheet or scrim closes it past Compose's thresholds; ArrowDown / ArrowUp move between items. Events: selection-change, open-change (detail: { open }, cancelable when Escape, the scrim or a drag ask). The behavior lives in the navigation-drawer-layout.js entry (NavigationDrawerLayout), which m3e-react's <NavigationDrawer> uses too.

Navigation suite

import '@kyralabs/m3e-elements/navigation-suite.js';
<m3e-navigation-suite label="Main" selected="home" style="block-size: 100dvh">
  <m3e-navigation-item value="home" icon="home" href="/">Home</m3e-navigation-item>
  <m3e-navigation-item value="search" icon="search" href="/search">Search</m3e-navigation-item>
  <button slot="primary-action" class="m3e-fab" aria-label="Compose">
    <span class="m3e-icon" aria-hidden="true">edit</span>
  </button>
  <main>…</main>
</m3e-navigation-suite>

<m3e-navigation-suite> (Compose NavigationSuiteScaffold) renders the .m3e-navigation-suite contract (load navigation-suite.css): its <m3e-navigation-item> children as the navigation the viewport's window size class calls for — a short navigation bar below 600px (horizontal items when the window is under 480px tall), a collapsed wide navigation rail from 600px, the expanded rail from 1200px — and every other child in the content area (slot="primary-action": above the bar, the rail's header, the drawer's first entry). Switching keeps the selection, the item elements (bar ↔ rail) and keyboard focus. layout-type fixes the type (short-navigation-bar-compact, short-navigation-bar-medium, wide-navigation-rail-collapsed, wide-navigation-rail-expanded, navigation-drawer, none); expanded-from (large default, a class or px bound, none for Compose's rule) and expanded-type (navigation-drawer) tune the default rule; the navigationSuiteType property replaces it with a function of the window size class. The read-only type is the type shown. navigation-hidden (or hideNavigation() / showNavigation() / toggleNavigation()) runs Compose's show / hide spring. Events: selection-change, navigation-hidden-change, navigation-suite-type-change (detail: { type }). The behavior lives in the navigation-suite-layout.js entry (NavigationSuiteLayout), which m3e-react's <NavigationSuiteScaffold> uses too.

Tabs

import '@kyralabs/m3e-elements/tabs.js';
<m3e-tabs label="Media" selected="photos">
  <m3e-tab value="videos" icon="videocam">Videos</m3e-tab>
  <m3e-tab value="photos" icon="photo">Photos</m3e-tab>
  <m3e-tab-panel value="videos">…</m3e-tab-panel>
  <m3e-tab-panel value="photos">…</m3e-tab-panel>
</m3e-tabs>

<m3e-tabs> (Compose PrimaryTabRow / SecondaryTabRow / PrimaryScrollableTabRow / SecondaryScrollableTabRow with Tab / LeadingIconTab) renders the .m3e-tabs contract (load tabs.css): a role="tablist" of .m3e-tabs from its <m3e-tab> children (value, optional icon, disabled, label-hidden), and makes each <m3e-tab-panel value> child the matching role="tabpanel" (labelled by its tab, hidden unless selected). variant: primary (default) or secondary; scrollable; icon-position="leading" puts icons before labels; label names the tablist; the host id prefixes the tab and panel ids. Activation is manual (Compose, m3.material.io): the arrow keys (mirrored in RTL), Home and End move the focus without wrapping, a press, Enter or Space selects. Selecting slides the indicator on Compose's spring and a scrollable row scrolls the selected tab to the centre. Event: selection-change (detail: { value }, cancelable). The behavior lives in the tabs-layout.js entry (TabsLayout), which m3e-react's <Tabs> uses too.

Checkbox

import '@kyralabs/m3e-elements/checkbox.js';
<m3e-checkbox name="mail" value="daily" checked>Daily</m3e-checkbox>
<m3e-checkbox indeterminate>Receive emails</m3e-checkbox>

<m3e-checkbox> renders the m3e-css .m3e-checkbox contract around a native <input type="checkbox">, which submits with its form and fires the native input / change events. It exists for what plain HTML cannot declare: the indeterminate attribute sets the input property, data-from keeps the previous state (a dash entered from unchecked draws instead of morphing from a check, as Compose does) and the ripple presses on Space or a pointer on the label. Attributes: checked, indeterminate, disabled, invalid (aria-invalid, error colors), required (aria-required), name, value, label (the input's aria-label when there is no text). A user toggle updates checked and clears indeterminate.

Chip set

import '@kyralabs/m3e-elements/chip-set.js';
<m3e-chip-set label="Recipients">
  <div class="m3e-input-chip" data-value="ada">
    <div class="m3e-input-chip__cell">
      <img class="m3e-input-chip__avatar" src="ada.jpg" alt="" />
      <span class="m3e-input-chip__label">Ada Lovelace</span>
      <button class="m3e-input-chip__remove">
        <span class="m3e-icon" aria-hidden="true">close</span>
      </button>
    </div>
  </div>
</m3e-chip-set>

<m3e-chip-set> renders the m3e-css .m3e-chip-set (load chip-set.css and the chip files) inside itself and moves its children, chips in the m3e-css markup, into it. The set is one tab stop with the arrow keys (mirrored in RTL, wrapping at the ends as React Aria's TagGroup does), Home and End between its chips (a filter chip's native checkbox, an assist or suggestion chip, an input chip). With input chips it is a role="grid" of role="row" chips wired as React Aria's TagGroup (m3e-react <InputChipGroup> renders the same DOM): Backspace or Delete on a focused input chip, or a press on its remove button (labelled "Remove "), emits remove (detail: { value, chip }, cancelable; value is data-value, else the label); unless canceled, the chip is removed and the focus moves to the next chip. Attributes: label (the set's accessible name), single-line (one scrolling row). Filter, assist and suggestion chips need no element: a filter chip is a native checkbox. The behavior lives in the chip-set-layout.js entry (ChipSetLayout), which m3e-react's <ChipSet> uses too.

Slider

import '@kyralabs/m3e-elements/slider.js';
<m3e-slider label="Volume" value="0.4"></m3e-slider>
<m3e-slider label="Percent" max="100" step="10" value="40" size="m" icon="volume_up"></m3e-slider>
<m3e-slider label="Price" range max="100" start-value="20" end-value="80"></m3e-slider>
<m3e-slider label="Balance" variant="centered" min="-50" max="50"></m3e-slider>
<m3e-slider label="Brightness" orientation="vertical" style="block-size: 200px"></m3e-slider>

<m3e-slider> renders the m3e-css .m3e-slider (load slider.css) inside itself, one native <input type="range"> per thumb (its role, value, labels and form name), and adds pointer presses and drags on the track (a range slider moves the closer thumb; the thumbs meet but never cross), the keyboard (arrows by one step, or 1 % of the range without one; Shift + arrow and Page Up / Page Down by a page; Home / End; horizontal arrows mirror in RTL, and a vertical slider's minimum is at the bottom), the handle shrink and value indicator while dragged (data-dragging), and input / change events (detail: { value }, a range { start, end }; change when a drag, press or key ends). Attributes: min (0), max (1), step, value, range with start-value / end-value (thumbs labelled start-label / end-label, default "Range start" / "Range end"), variant (standard | centered), orientation (horizontal | vertical; the booleans centered and vertical are deprecated), size (xs default, s, m, l, xl), icon (an inset icon, M–XL), disabled, label, name. The value math is m3e-motion's port of Compose's Slider.kt; the slider-layout.js entry (class names, custom properties, input attributes, value text) is shared with m3e-react's <Slider> / <RangeSlider>, which render the same DOM.

Text field

import '@kyralabs/m3e-elements/text-field.js';
<m3e-text-field label="Email" name="email" type="email"></m3e-text-field>
<m3e-text-field variant="outlined" label="Bio" maxlength="20" supporting-text="Public"></m3e-text-field>
<m3e-text-field label="User" invalid error-text="Already taken" required></m3e-text-field>
<m3e-text-field variant="outlined" label="Notes" multiline></m3e-text-field>

<m3e-text-field> renders the m3e-css .m3e-text-field (load text-field.css) inside itself around a native <input> (or, with multiline, a <textarea>) that takes part in forms and fires the native input / change events. The label float, colors and outlined notch need no script (the plain markup works without this element); the element adds the live character counter (maxlength: "n/max", read as "Character count, n/max") and grows the textarea where CSS field-sizing: content is missing. Attributes: variant (filled default, outlined), label, label-position (above), value, placeholder, type, name, autocomplete, inputmode, supporting-text, error-text (shown instead while invalid, with the "alert" role), invalid, disabled, readonly, required, maxlength, multiline, leading-icon, trailing-icon, prefix-text, suffix-text, counter-label. The text-field-layout.js entry (class names, counter text, describedby, textarea growth) is shared with m3e-react's <TextField>, which renders the same DOM.

List

<m3e-list label="Pets" selection-mode="single">
  <div class="m3e-list-item" data-value="cat">
    <span class="m3e-list-item__content"><span class="m3e-list-item__headline">Cat</span></span>
  </div>
  <div class="m3e-list-item" data-value="dog" aria-selected="true">
    <span class="m3e-list-item__content"><span class="m3e-list-item__headline">Dog</span></span>
  </div>
</m3e-list>

Static lists need no script (<ul class="m3e-list"> of <li class="m3e-list-item">, load list.css). <m3e-list> renders an interactive .m3e-list inside itself and moves its .m3e-list-item children (m3e-css markup; data-value keys them, aria-selected="true" and aria-disabled="true" set the initial state) into it: a role="listbox" of options with one tab stop, arrow keys that wrap (Down / Right next, Up / Left previous), Home / End and typeahead. selection-mode="single" | "multiple" (selection is its deprecated name): click, Space or Enter toggles an option and emits selection-change (detail: { values, value }, cancelable); a checkbox, radio button or switch in a slot is made inert and shows the option's state. selection-mode="none" (the default): click or Enter emits action (detail: { value }). Also label, disallow-empty-selection and the selectedValues property. The list-layout.js entry (ListLayout, item class names, the indicator markup) is shared with m3e-react's <List>, which renders the same DOM.

Tooltip

<button id="fav" class="m3e-icon-button" aria-label="Favorite">…</button>
<m3e-tooltip anchor="fav">Add to favorites</m3e-tooltip>

<m3e-tooltip anchor="info" variant="rich" subhead="Permissions" action="Request access" persistent>
  Configure permissions for selected service accounts.
</m3e-tooltip>

<m3e-tooltip> renders a .m3e-tooltip popover inside itself, moves its own child nodes into it as the text and adds its id to the anchor's aria-describedby. It runs Compose's TooltipBox behavior: mouse hover shows it at once; it stays while the anchor or the tooltip is hovered and hides 1.5s after the pointer leaves both (m3.material.io); a touch long press shows it until the press ends (at least --m3e-comp-tooltip-duration); keyboard focus shows it until focus leaves; Escape and a click outside hide it; one tooltip shows at a time. position (above | below | left | right | start | end, Compose TooltipAnchorPosition), caret, persistent (stays until a click outside or Escape), manual (no hover / focus / long press), open (reflected), show() (hides again after the duration unless persistent) and hide(). A rich tooltip with an action is a non-modal role="dialog" that Tab from the anchor moves into; the action emits a cancelable action event and hides the tooltip. Events: open-change (detail: { open }). The tooltip-popup.js entry (TooltipController, TooltipPopup) is shared with m3e-react's <TooltipTrigger>, which renders the same DOM.

Dialog

<button class="m3e-button m3e-button--filled" onclick="discard.show()">Discard</button>
<m3e-dialog id="discard" headline="Discard draft?" icon="delete">
  This permanently deletes the draft.
  <button class="m3e-button m3e-button--text" slot="actions">Cancel</button>
  <button class="m3e-button m3e-button--text" slot="actions">Discard</button>
</m3e-dialog>

<m3e-dialog variant="full-screen" headline="New event">
  …form…
  <button class="m3e-button m3e-button--text" slot="actions">Save</button>
</m3e-dialog>

<m3e-dialog> renders a native <dialog class="m3e-dialog"> inside itself (the alert dialog has role="alertdialog", named by headline and described by the text) and moves its own child nodes into it: slot="actions" children into the actions row (the dismiss button first), the rest into the content. variant: alert (default; Compose AlertDialog, with an optional icon), custom (Compose BasicAlertDialog: the children draw their own surface; named by label, default "Dialog") or full-screen (m3.material.io: a header with a close button, named by close-label, default "Close"; full-screen below 600px, a basic dialog from 600px). open (reflected), show(), close(). It opens with showModal() (the page inert, the scroll locked, Tab kept inside, focus on the first control and back on the opener when it closes) and animates in and out; Escape, the scrim, the close button and browser close requests emit a cancelable open-change (detail: { open }; preventDefault() keeps the dialog open). The dialog-layout.js entry (DialogLayout) is shared with m3e-react's <AlertDialog>, <BasicAlertDialog> and <FullScreenDialog>, which render the same DOM.

Snackbar

<m3e-snackbar-host id="snackbars"></m3e-snackbar-host>
<script type="module">
  const result = await snackbars.showSnackbar({ message: 'Message archived', actionLabel: 'Undo' });
  if (result === 'action-performed') undoArchive();
  snackbars.showSnackbar('Saved'); // 4s, no action
</script>

<m3e-snackbar-host> renders a .m3e-snackbar-host inside itself: a polite live region (role="status") fixed to the bottom center of the window (raise it above a FAB with --m3e-comp-snackbar-host-bottom-offset) that shows one snackbar at a time (Compose SnackbarHost). showSnackbar(message | { message, actionLabel, withDismissAction, duration, signal }) queues one and resolves with 'action-performed' or 'dismissed'; duration is short (4s, the default without an action), long (10s) or indefinite (the default with an action). The timer pauses while the snackbar is hovered or focused, Escape in it dismisses it, and focus() moves focus to its action (bind it to a shortcut of your app). The dismiss button (withDismissAction, named by dismiss-label, default "Dismiss") has the plain tooltip Compose builds in. action-on-new-line puts the action under the text. hostState is the queue (a SnackbarHostState, which can also be shared or driven from code); the snackbar-layout.js entry (SnackbarHostState, SnackbarHostController) is shared with m3e-react's <SnackbarHost>, which renders the same DOM.

Bottom sheet

<button class="m3e-button m3e-button--filled" onclick="sheet.show()">Show bottom sheet</button>
<m3e-bottom-sheet id="sheet" variant="modal">…content…</m3e-bottom-sheet>

<div style="position: relative; block-size: 480px">
  <main>…</main>
  <m3e-bottom-sheet value="partially-expanded" peek-height="128" skip-hidden-state label="Details">
    …content…
  </m3e-bottom-sheet>
</div>

<m3e-bottom-sheet> (Compose BottomSheet / ModalBottomSheet) renders a .m3e-bottom-sheet inside itself (load m3e-css bottom-sheet.css); its child nodes are the content. variant: standard (default; fixed to the bottom of its positioned container, a region named label) or modal (a native <dialog> over a scrim: the page inert, Tab kept inside, focus back on the opener when it hides). value (reflected): hidden (default), partially-expanded or expanded; open (reflected) is whether value is not hidden, and setting it shows or hides the sheet like show() / hide(). skip-partially-expanded drops the partially expanded state; peek-height (px, standard only; empty: 56) shows that much of the sheet when partially expanded (Compose sheetPeekHeight) and skip-hidden-state then drops hidden. no-drag-handle, drag-handle-label (default "Drag handle"; the handle has the plain tooltip Compose builds in), gestures-disabled and label (default "Bottom Sheet"). Methods show(), hide(), expand(), partialExpand(). When the sheet asks to move (the handle, a drag, Escape, the scrim, a method) it fires a cancelable value-change (detail: { value }; preventDefault() keeps the current value) and then, when that shows or hides it, a cancelable open-change (detail: { open }), as the other modal elements do; setting value or open fires nothing. The behavior lives in the bottom-sheet-layout.js entry (BottomSheetLayout), imported when the element first renders (ready resolves once it runs) and shared with m3e-react's <BottomSheet> and <ModalBottomSheet>, which render the same DOM.

Side sheet

<div style="display: flex; block-size: 100dvh">
  <main style="flex: 1; min-inline-size: 0">…</main>
  <m3e-side-sheet id="details" headline="Details" open>…</m3e-side-sheet>
</div>

<m3e-side-sheet id="filters" variant="modal" headline="Filters" back-button detached divider>
  …filters…
  <button class="m3e-button m3e-button--filled" slot="actions">Save</button>
  <button class="m3e-button m3e-button--outlined" slot="actions">Cancel</button>
</m3e-side-sheet>

<m3e-side-sheet> (D8: m3.material.io only) renders a .m3e-side-sheet inside itself and moves its own child nodes into it: slot="actions" children into the bottom actions, the rest into the content, which scrolls on its own. variant: standard (default; an <aside> that, as the last child of a flex row, takes the sheet's width from the row while open, so the content beside it shrinks), modal (a native <dialog> at the window's end edge over a scrim: the page inert, the scroll locked, Tab kept inside, focus on its first button and back on the opener when it closes) or adaptive (modal below 600px, the medium window size class, standard from it; it switches when the window crosses 600px and stays open). The header has the close button m3.material.io requires (close-label, default "Close"), the headline (it names the sheet; without one, label, default "Side sheet") and, with back-button (deprecated name: back), a back button (back-label, default "Back") that fires back. detached insets it by 16px with all corners rounded; divider draws the standard sheet's line along its start edge, or the modal sheet's line above its actions. open (reflected), show(), close(), toggle(); the close button, Escape and the scrim emit a cancelable open-change (detail: { open }). It opens on the default spatial spring and closes on the fast effects spring (the navigation drawer's motion, mirrored). The side-sheet-layout.js entry (SideSheetLayout) is shared with m3e-react's <SideSheet>, which renders the same DOM.

Carousel

<m3e-carousel label="Featured" item-width="186" item-spacing="8" content-padding="16">
  <m3e-carousel-item><img src="1.jpg" alt="…" /></m3e-carousel-item>
  <m3e-carousel-item href="/albums/2"><img src="2.jpg" alt="…" /></m3e-carousel-item>
  <m3e-carousel-item action label="Play the third"><img src="3.jpg" alt="…" /></m3e-carousel-item>
</m3e-carousel>

<m3e-carousel> renders a .m3e-carousel inside itself and moves each <m3e-carousel-item>'s child nodes into an item's mask. layout: multi-browse (default), uncontained, hero, centered-hero (Compose's keylines: items grow and shrink as they scroll and stay pinned together) or full-screen (one item per screen, scrolling vertically). item-width (px) is the preferred large item width (multi-browse, hero; hero without it fills the carousel but for the small items) or the item width (uncontained). Items snap natively one at a time (the uncontained layout does not snap); every item is a tab stop and the arrow keys, Home and End move between them, scrolling the focused item into place. href items are links, action items buttons (activating one dispatches action with detail: { index } from the item, bubbling through the carousel; the carousel's item-click is deprecated). label names the carousel; items are named "n of N" (label on an item, or itemLabel). scrollToItem(index), currentItem; ready resolves once the carousel-layout.js entry (CarouselLayout, imported on first render) runs. It is shared with m3e-react's <Carousel>, which renders the same DOM.

Pull to refresh

<button type="button" aria-label="Trigger Refresh" id="refresh">…</button>
<m3e-pull-to-refresh id="feed">
  <ul>…</ul>
</m3e-pull-to-refresh>
<script type="module">
  const feed = document.getElementById('feed');
  feed.addEventListener('refresh', async () => {
    feed.refreshing = true;
    await load();
    feed.refreshing = false;
  });
  document.getElementById('refresh').addEventListener('click', () => feed.refresh());
</script>

<m3e-pull-to-refresh> renders a .m3e-pull-to-refresh inside itself (it fills its container's height) and moves its child nodes into the scroll container, under Compose's pull indicator. A touch or pen pull down at the top of the content brings the indicator down (half the drag, slowing past the 80dp threshold); a release past the threshold fires refresh. Mouse drags and wheels never pull: give keyboard and mouse users a visible Refresh button that calls refresh() (the same event). refreshing (reflected) is the app's state: the indicator rests at the threshold and spins meanwhile. indicator="loading-indicator" uses contained <m3e-loading-indicator>s, whose entry it imports on first use; disabled, threshold (px), indicator-label (default "Refresh"), distanceFraction. The pull-to-refresh-layout.js entry (PullToRefreshLayout) is shared with m3e-react's <PullToRefresh>, which renders the same DOM.

Time pickers

<m3e-time-picker hour="9" minute="41" hour-cycle="12"></m3e-time-picker>
<m3e-time-input hour="21" minute="30" vibrant></m3e-time-input>
<m3e-time-scroll hour="6" minute="45" uncontained></m3e-time-scroll>
<script type="module">
  document.querySelector('m3e-time-picker').addEventListener('change', (e) => {
    console.log(e.detail.hour, e.detail.minute);
  });
</script>

Compose's TimePicker (the dial), TimeInput and TimeScroll. Each renders a <div> inside itself (the host is display: contents) with the m3e-css contract. Attributes: hour (0–23), minute, hour-cycle="12|24" (default: the locale's, from the nearest lang), selection="hour|minute", vibrant; the dial adds layout="auto|vertical|horizontal" (auto: horizontal in a landscape window), the input and the wheels uncontained (a side column with AM, PM and a mode toggle that fires mode-toggle). Property labels overrides the English strings. Events: change (detail: { hour, minute }; the inputs' native input / change stop inside) and selection-change (detail: { value }; its selection key is deprecated). The dial takes taps, drags and arrow keys (Enter picks); the input fields and the wheels are spin buttons (Up / Down). For a dialog, put a picker in a .m3e-time-picker-dialog surface inside <m3e-dialog variant="custom">. The time-picker-layout.js entry (TimePickerLayout, TimeInputLayout, TimeScrollLayout) is shared with m3e-react.

Date pickers

<m3e-date-picker value="2025-08-17"></m3e-date-picker>
<m3e-date-range-picker start="2025-08-10" end="2025-08-20"></m3e-date-range-picker>
<m3e-docked-date-picker value="2025-08-17" label="Date"></m3e-docked-date-picker>
<script type="module">
  document.querySelector('m3e-date-picker').addEventListener('change', (e) => {
    console.log(e.detail.value); // "2025-08-05", or null
  });
</script>

Compose's DatePicker (calendar, year grid, date input) and DateRangePicker, and m3.material.io's docked date picker. Each renders a <div> inside itself (the host is display: contents) and imports its layout entry on first render (ready resolves once it has rendered); the input mode's date-field entry loads the first time it shows. Dates are ISO (YYYY-MM-DD, UTC). Attributes: value (range: start, end), displayed-month (YYYY-MM), year-range="1900-2100", display-mode="picker|input", hide-mode-toggle, no-title, no-headline; docked: value, label, year-range and show() / close(). Properties selectableDates ({ isSelectableDate(utcMillis), isSelectableYear(year) }) and labels. Events: change (detail: { value }, range { start, end }; the date fields' native input / change stop inside), display-mode-change, displayed-month-change. The first day of the week, names, digits and the input's pattern come from Intl for the nearest lang; the input takes the pattern's digits and shows the delimiters as they come (Compose's transformations). Show the range picker in <m3e-dialog variant="full-screen"> and the picker in <m3e-dialog variant="custom"> around a .m3e-date-picker-dialog surface.

Ripple for plain HTML

m3e-css draws hover, focus and pressed overlays without JS; the press ripple needs a little. One import adds it to every plain-HTML button, icon button, chip (assist, filter, input, suggestion), FAB, extended FAB, FAB menu item, .m3e-state-layer, interactive card (pressed through .m3e-card__action, drawn on the card), interactive list item, navigation item (drawn on .m3e-navigation-item__state), navigation drawer item, tab, checkbox, radio button and switch (pressed through the label or the input, drawn on the 40dp __state circle; a switch's moves with its handle) in the document, including ones added later:

import '@kyralabs/m3e-elements/ripple.js';

It uses one set of delegated pointer and keyboard listeners on document and writes the same contract as attachRipple: the first press on a host sets data-ripple, which stays, and each press toggles data-pressed and writes --_m3e-ripple-x/-y/-radius. Hosts carrying a data-ripple it did not set (m3e-react components and attachRipple hosts) are skipped, as are disabled ones. Enter and Space press from the center (links: Enter only; checkboxes, filter chips, radio buttons and switches: Space only, as they toggle or select). To scope it or stop it, call attachAutoRipple(root, signal) from the package root instead; that import has no side effect, and aborting the signal removes the attributes and variables it wrote.

Canonical layouts

import '@kyralabs/m3e-elements/list-detail-layout.js'; // or supporting-pane-layout.js
<m3e-list-detail-layout list-label="Inbox" detail-label="Message" drag-handle style="block-size: 100%">
  <div slot="list">
    <button data-pane-navigate="detail" data-content-key="42">Brunch this weekend?</button>
  </div>
  <div slot="detail">
    <button data-pane-navigate="back" aria-label="Back">…</button>
    …
  </div>
  <div slot="extra">…</div>
</m3e-list-detail-layout>

<m3e-list-detail-layout> and <m3e-supporting-pane-layout> (Compose ListDetailPaneScaffold / SupportingPaneScaffold with their navigators) render the .m3e-layout > .m3e-pane-scaffold contract (load layout.css) and size themselves by their own width: one pane below 840px, two from 840px, three (with an extra pane) from 1600px (m3.material.io) — pane-rule="compose" shows three from 1200px (Compose's calculatePaneScaffoldDirective). navigateTo(pane, contentKey?) (list / detail / extra, main / supporting / extra), navigateBack(behavior?), canNavigateBack() and the navigator property (an m3e-motion ThreePaneScaffoldNavigator) drive them; a click on [data-pane-navigate] does too, and [data-pane-navigate="back"] hides while there is nothing to go back to. Panes move on Compose's pane motion, focus follows the destination, and drag-handle adds the pane expansion handle. Event: scaffold-value-change. The behavior is the pane-scaffold-layout.js entry (PaneScaffoldLayout, also used by m3e-react); the elements load the pane-motion.js and pane-expansion.js entries lazily.

Apache-2.0; see LICENSE and NOTICE.