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

@scattered-light/loomiere-base-ui

v0.21.3

Published

Base UI components styled by any Loomiere theme. Brand-free: every value is a --sl- variable.

Readme

@scattered-light/loomiere-base-ui

Base UI components styled by any Loomiere theme. The styles hold no brand: every color, size and font is a --sl- variable, so the same components look like whichever theme you load.

npm install @scattered-light/loomiere-base-ui @base-ui/react @material-symbols/font-400

Load a theme, then the component styles:

import './theme/tokens.css';        // from loomiere build, or @scattered-light/loomiere-mojave/tokens.css
import './theme/utilities.css';
import '@scattered-light/loomiere-base-ui/styles.css';

The document reset in styles.css sits in a CSS reset layer, so any unlayered rule wins over it.

With Tailwind CSS v4, load tailwind.css instead. It holds the same styles, with the reset in Tailwind's base layer and the components in its components layer, so utility classes win over both (className="Button px-6"):

@import url('<your brand fonts>');   /* font imports go first, or in a <link> */
@import 'tailwindcss';
@import './theme/exports/tailwind.css';   /* the theme's Tailwind classes; brings tokens.css along */
@import '@scattered-light/loomiere-base-ui/tailwind.css';

Use Base UI as usual, with the class names the styles target, or the few wrappers this package exports:

import {
  Accordion, AlertDialog, Autocomplete, Avatar, Badge, Button, Calendar, Checkbox, CheckboxGroup, Collapsible,
  AppBar, AppBarSpacer, Combobox, ContextMenu, DatePicker, DatePresets, DescriptionItem, DescriptionList, Dialog, Drawer, EmptyState, Field, Form, Input, ListDetail, Menu, Menubar, Meter, MoneyField, NumberField, PickerButton, PickerSplit, PickerSplitMain, PickerSplitTrigger,
  OTPField, Popover, PreviewCard, Progress, Radio, ScrollArea, Select, Separator, Sheet, SideNav, SideNavGroup, SideNavItem, SideNavToggle, Slider, Splitter, SplitButton, StatCard, StatFigure, Switch,
  Tabs, Toast, Toggle, ToggleGroup, Toolbar, Tooltip, useListDetail,
} from '@scattered-light/loomiere-base-ui';

Base UI version

The styles are written against Base UI baseUi.target in package.json (1.9.0), and the peer dependency starts there. Every published CSS file names that release in its first line. npm run check:base-ui fails if an older Base UI is installed and warns if a newer minor one is. That check, and baseUi.target, cover Base UI parts only. Custom components are left out of it. Check MoneyField when Number Field changes: it is built on NumberField.Root, and a new root prop should keep working without a change here. Money field's default maximum is 10^15 - 1 minor units. A major amount with more than 15 significant digits is not exact as a float, and Number field counts in floats. formatMoney passes the exact decimal string to Intl.NumberFormat. Number field 1.9 leaves a controlled input showing the typed text on blur when the number did not change (12.5 stays 12.5 instead of 1,234.50). Money field rewrites that text after Number field's layout effect, without remounting. Drop the rewrite when Number field formats this case itself. Check Collection when Checkbox, Tabs, Select or Menu change. Check Collection when the @tanstack/react-table major changes.

Data attributes for analytics and tests

Every component passes data-* attributes (and id) to the element it renders, so product analytics and guide tools such as Pendo or UserPilot, and tests, can target a stable name instead of class names or nesting. Base UI parts and most custom components spread all their props. The components whose root is not one plain element (DataTable, DataList, CollectionCards, CollectionToolbar and its parts, CollectionPagination, EditableList, DatePicker, Calendar, DatePresets) take data-* and id only. DatePicker puts id on its trigger and data-* on the field around it. CollectionCards puts them on the built-in empty and loading states when there are no cards, and leaves a custom empty element for the app to tag.

Inside those components, the parts an app cannot reach carry fixed names, so one tag on the component is enough: [data-ui="clients.toolbar"] [data-part="search"].

  • Rows: DataTable rows, DataList rows and CollectionCards items carry data-row-id (from getRowId); EditableList rows carry it too. Keep row ids opaque: they are in the DOM, where an analytics tool can read them.
  • Cells: DataTable headers and cells, and EditableList cells, carry data-column.
  • data-part: row-open, row-toggle, sort-header, select, select-all, select-group, group-toggle, row-menu, row-action, total, empty, no-results, loading, clear-filters in the views; search, search-clear, tabs, segment, filter, group-by, sort, sort-column, sort-direction, meta, meta-end, actions, selection-bar, bulk-actions, clear-selection in the toolbar; summary, previous, next, page-size in the pager; trigger, clear, popup, apply, cancel, day, preset in the date picker; remove-row, add-row, error, balance in EditableList; main, more, item in SplitButton; open on a StatCard's title link.
  • Options and actions: toolbar tabs, segments, selects and date presets put the option on data-value. A ToolbarSegment has an id (set as data-segment; segmentFilter uses the column id and segmentSort uses sort). RowAction and SplitButtonItem take an id, set as data-action, because a label can change and the id should not.

test/passthrough.test.mjs renders each component with a data-ui attribute and fails if one drops it. Add a line there for a new component.

Custom components

A custom component is one Base UI does not have. It is built here from concepts and tokens that already exist (emphasis, status, type, space), not as a new package. React lives in src/custom/, CSS in styles/custom/. Both export from the package root and from @scattered-light/loomiere-base-ui/custom. Table is custom too: plain HTML, with no React wrapper. A Badge that is only a number needs an accessible name: aria-label (for example "3 unread") or visually hidden text. Collection is the exception to the main export: it lives at @scattered-light/loomiere-base-ui/custom/collection so an app that only imports Badge does not have to install TanStack.

DatePicker

A field that opens a calendar for one date or a period. The developer sets mode. The person using it cannot switch. Calendar is the month grid on its own. DatePresets is the quick-selection list. The preset today is the quick selection; todayDate() is the local calendar day.

| Prop | What it does | | --- | --- | | mode | single or range. Defaults to single. | | value, defaultValue, onValueChange | An ISO date, or { start, end } for a range. null clears a single date. | | defaultMonth | The month to open on when there is no value yet. Any day in that month. | | months | 1, 2, 3, or auto (2 for a range, 1 for a single date). The panel can only show fewer: one month under 32rem, in a drawer, or for a coarse pointer under 48rem; at most two under 40rem. | | min, max | Inclusive ISO dates. Days outside them stay visible and disabled. | | isDateDisabled | Extra days to disable, such as weekends. | | minLength, maxLength | Inclusive length of a range. | | presets | true (the default list), false, or your own. | | weekStartsOn | 0 Sunday through 6 Saturday. The locale decides when this is omitted. | | locale, showOutsideDays, showWeekNumbers | Formatting, trailing days, and ISO week numbers. | | commit | immediate writes as soon as a day is picked. apply keeps a draft until Apply or Cancel. The default is immediate for one date and apply for a range. | | clearable, placeholder, formatValue | The trigger. | | name, nameStart, nameEnd, required | Submitted through a visually hidden input so required and Field validation see the value. | | disabled, readOnly | Disabled leaves the tab order. Read-only stays focusable, announces the value, and does not open. | | open, defaultOpen, onOpenChange, popup | popup is auto, popover, or drawer. | | label | Names the trigger and the dialog when there is no Field.Label. The value is described, not part of the name. |

A range is a start click, then an end click. An end before the start swaps. Clicking the start again makes a one-day range. Hover or focus previews the band until the end is chosen; the preview is not announced as selected. Immediate mode writes { start, end: null } on the first click and leaves the popup open, then writes the finished range and closes on the second click. Apply mode keeps that draft until Apply or Cancel.

Escape on a half-picked range clears the start and stays open. A second Escape closes. Escape in the month or year chooser returns to the days. Clicking outside, or any other close, throws the draft away and shows the committed value again. In immediate mode the half range is already committed, so that close keeps it.

Page Down with two months showing shifts the view by one month, so the focused day lands in the last column. Direction is read when the popup opens. A dir change while it is open is not picked up.

When you add one:

  • Add src/custom/<name>.tsx and styles/custom/<Name>.css, and import the CSS from styles/index.css in the custom block (after the Base UI imports).
  • Export it from src/index.ts and src/custom/index.ts.
  • List it in build/build-design-system.mjs: GROUPS, WRAPPERS when it has a React component, and CUSTOM.
  • Add it to the brand book in packages/loomiere/exporters/design-system.mjs: the wrappers sentence, the "which component for what" list, and the custom-components line.
  • Add src/stories/components/<Name>.stories.tsx (autodocs, ds:lib). The default story is the Design System preview.
  • Mark it included in src/stories/OverviewChecklist.stories.jsx and src/stories/README.md.
  • If MUI or Chakra already has the component, point that override at the same variables through exporters/component-styles.mjs.
  • If it paints with --sl-e-*, give its default level a selector in DEFAULT_LEVELS (packages/loomiere/build/build-tokens.mjs).
  • Add a Figma page script under packages/loomiere/figma/library/pages/ and a step in run-order.json. Leave check/mojave.json until that page has been built in Figma.

Splitter

A window splitter. The handle is a separator you drag or move with the arrow keys. One panel has no size and fills what the others leave. Sizes can mix pixels and percent. id is the panel's key in storage and in a controlled layout. The DOM id is that key prefixed with the splitter's useId, so two splitters can both name a panel main, and aria-controls points at the element. Pass layout to control the sizes: the component renders from it and only reports changes. Children are Splitter.Panel and Splitter.Handle. A Fragment is flattened (a .map() can return one). Any other child throws in development, because a wrapper would hide its panel. Escape during a drag puts the sizes back.

<Splitter.Root orientation="horizontal" storageKey="workstation">
  <Splitter.Panel id="nav" defaultSize="240px" minSize="180px" maxSize="400px" collapsible />
  <Splitter.Handle aria-label="Resize navigation" />
  <Splitter.Panel id="main">…</Splitter.Panel>
</Splitter.Root>

ListDetail

The detail of one selected item. Wide, it is a Splitter. Narrow, the detail opens in a Drawer, or replaces the list. The list stays in the first panel the whole time, so its scroll survives opening, closing, and the width change. The app keeps the selected id. useListDetail() returns the layout and a close(). headingLevel is the detail heading in every layout, including the drawer. listLabel names the list as a region.

<ListDetail.Root open={!!selectedId} onOpenChange={(open) => !open && setSelectedId(null)} storageKey="register">
  <ListDetail.List>
    <table className="Table">…</table>
  </ListDetail.List>
  <ListDetail.Detail title="Transaction 1042" closeDisabled={dirty}>…</ListDetail.Detail>
</ListDetail.Root>

EmptyState

The block a page, pane, table, list or popup shows when it has nothing to show. kind picks the default icon, the media tone and whether a screen reader hears it. The title is required. Everything else is optional, so a title on its own is a valid empty state. It does not choose button emphasis: the first action is the main one (bold for start, standard for the other kinds) and a secondary action is quiet. Media and Title read kind and size from the root. Their own props override that. The announcement is a visually hidden live region that starts empty and fills one frame later, so the words are a change. done stays quiet on the render that mounts it, and is read when the kind becomes done after that.

<EmptyState
  kind="no-results"
  title="No transactions match"
  description="Try a different date range, or clear the filters."
  actions={<Button emphasis="standard" onClick={clearFilters}>Clear filters</Button>}
/>

A filtered table keeps its header. The empty row does not hover:

<tr>
  <td data-empty colSpan={4}>
    <EmptyState size="compact" kind="no-results" title="No transactions match" />
  </td>
</tr>

Combobox.Empty, Autocomplete.Empty and Menu.Empty stay the popup's own slots. Put a compact empty state inside one:

<Combobox.Empty>
  <EmptyState size="compact" kind="no-results" title="No fruits match" />
</Combobox.Empty>

Working papers: Sheet, StatFigure, DescriptionList and ruled totals

Pieces for pages that read like an accountant's working paper. They use the theme's rule color (rule.color) and the caps-condense and figures-* text styles, so they follow the brand.

Sheet is a section with a heavier rule across its top and an optional tab label sitting on the rule. The label names the section; without one, give it an aria-label. action sits at the end of the header row. Content runs edge to edge, and a Table, DataTable or DataList directly inside drops its own frame; inset pads free content instead.

<Sheet label="Profile" action={<a href="/entity/edit">Edit</a>}>
  <DescriptionList variant="leader">
    <DescriptionItem label="Legal name">North Company LLC</DescriptionItem>
    <DescriptionItem label="Fiscal year">Starts January</DescriptionItem>
  </DescriptionList>
</Sheet>

StatFigure is one large figure with a caps label and an optional caption. underline="double" is the double rule under a final total, tone="accent" colors a figure that asks for action, and size="sm" fits a row of figures in a card. Pass the figure already formatted ("(40.00)").

<StatFigure label="Book balance" value="75.00" caption="DR · Oct 29" underline="double" />

StatCard is a card for one thing a page lists (an Entity, an account): a heading, a caption, and a row of figures ruled off under them. Give it href or onOpen and the whole card opens through its title, with one tab stop named by the title. headingLevel fits it to the page's outline. footer adds a line under the figures, and tag puts a small mark (a count of what is new, a status) at the top right. details adds a leader list of label and value pairs, and status and actions add a ruled bar at the foot (who it waits on, a way in).

<StatCard
  title="North Company"
  caption="USD · 1 account"
  onOpen={() => open('north')}
  facts={[
    { label: 'Questions', value: '1', tone: 'accent' },
    { label: 'Docs', value: '–' },
    { label: 'Review', value: '2', tone: 'accent' },
  ]}
/>

DescriptionList holds DescriptionItem pairs (a dt and a dd). The default sets labels in a column beside their values; variant="leader" runs a dotted leader from each label to its value at the end of the row.

Totals in a plain table are rows: <tr data-total="subtotal"> rules a line above, <tr data-total="grand"> adds a double rule under. Put data-rule="none" on label and blank cells so the rules only run under the figures. In a DataTable, totalStyle="subtotal" or "grand" does the same to the footer (the default, "band", keeps the header tray).

A tree in a DataTable (getSubRows) has options for a register of parents and children, such as clients and their Entities:

  • useCollection({ pageBy: 'top-level', pageSize: 5 }) pages by parents, so a page holds whole parents with their children. The pager counts parents: pass collection.pageTotal as total. collection.pageRows is the leaf rows of the page, children of collapsed parents included.
  • totalRows={[{ scope: 'page', label }, { scope: 'all', label }]} writes several footer rows. A page row totals the current page and is ruled as a subtotal; an all row totals every filtered row and is ruled as the grand total. label is text or a function of the collection, such as (c) => \Page ${c.page} of ${c.pageCount} subtotal``.
  • A rowHeader column's cell now draws the row's name: put a second line or a count beside it. The row's link or button keeps the plain name as its accessible name.
  • treeLines also gives parent, child and total rows their own heights, from collection.tree-row-height-parent, tree-row-height-child, total-row-height-subtotal and total-row-height-grand (all the dense row until a brand sets them), and starts the elbow collection.tree-line-offset in. With disclosure="small" the chevron takes collection.disclosure-color. A row with onRowActivate or getRowHref shows a pointer. table.row-hover-color is the text color on the hovered row.
  • CollectionToolbar takes searchWidth="fixed" (the search keeps collection.search-fixed-width), meta and metaEnd (quiet text beside the search and at the far end of the row) and framed (padding and a rule under it, inside a Sheet).
  • treeLines draws an elbow from each child's name back up to its parent. disclosure="small" shrinks the expand button so a parent is as tall as its children. getRowKind={(row) => 'link'} marks a row (data-kind="link") whose name reads as a link, for a row such as "3 more Entities".
  • CollectionToolbar takes searchPlaceholder. DataTable takes headerStyle="caps" (column names and total-row labels in caps-condense) and figures (end-aligned cells in the figures family). With treeLines, parent rows take collection.tree-parent-row-background and collection.tree-parent-row-hover.

App bar: AppBar, PickerButton and a segmented ToggleGroup

AppBar is the bar across the top of an app window: one row on a line in the rule color, so an ink-rule brand gets the working-paper rule under it. AppBarSpacer pushes what follows it to the end, and a vertical Separator is a short divider between icon actions. Icon actions are quiet Buttons holding only an icon, with an aria-label.

PickerButton names what it picks with a caps label before the choice. It opens nothing itself; render it as a trigger:

<AppBar>
  <strong>tullo</strong>
  <Menu.Root>
    <Menu.Trigger render={(props) => <PickerButton {...props} label="Entity" value={entity.name} />} />
    {/* Menu.Portal, Positioner, Popup, RadioGroup... */}
  </Menu.Root>
  <AppBarSpacer />
  <ToggleGroup data-variant="segmented" aria-label="Modules" value={[module]} onValueChange={...}>
    <Toggle value="accounting">Accounting</Toggle>
    <Toggle value="tax">Tax</Toggle>
  </ToggleGroup>
  <Button emphasis="quiet" aria-label="Documents"><DocumentIcon /></Button>
  <Separator orientation="vertical" />
  <Button emphasis="quiet" aria-label="More"><MoreIcon /></Button>
</AppBar>

PickerSplit is the same field with two parts, for a choice that is also a link or action: PickerSplitMain (the label and choice, a button) and PickerSplitTrigger (a square chevron button that opens the list). Anchor the popup to the root with a ref.

<PickerSplit ref={anchor}>
  <PickerSplitMain label="Client" value={client.name} onClick={openClient} />
  <Menu.Root>
    <Menu.Trigger render={(props) => <PickerSplitTrigger {...props} aria-label="Choose client" />} />
    <Menu.Portal><Menu.Positioner anchor={anchor}>…</Menu.Positioner></Menu.Portal>
  </Menu.Root>
</PickerSplit>

data-variant="segmented" on a ToggleGroup joins its toggles in one outline (toggle-group.segmented-border-color, the rule color) and fills the pressed one solid (fill.inverse), for switching between a few views or modules.

Side navigation: SideNav

SideNav is the column of links down the side of a workspace, named by aria-label. SideNavItem is a row (a link): current marks the page you are on, count shows a figure at the end, and a disabled row takes a hint. SideNavGroup adds a caps heading over a set of rows. SideNavToggle sits inside the SideNav on its edge and collapses it; the parent sets the column's width and must not clip it (overflow: visible).

<SideNav aria-label="Entity navigation" collapsed={collapsed}>
  <SideNavItem current href="/entity">Entity Dashboard</SideNavItem>
  <SideNavItem count={5} href="/open">Open Items</SideNavItem>
  <SideNavGroup label="Books">
    <SideNavItem href="/accounts">Financial Accounts</SideNavItem>
  </SideNavGroup>
  <SideNavToggle collapsed={collapsed} onCollapsedChange={setCollapsed} />
</SideNav>

Collection

Install the peer, then import the views from their own entry. EditableList does not need it, and comes from ./custom like Badge.

npm install @tanstack/react-table
import { DataList, DataTable, CollectionToolbar, CollectionPagination, CollectionCards, defineColumns, useCollection } from '@scattered-light/loomiere-base-ui/custom/collection';
import { EditableList } from '@scattered-light/loomiere-base-ui/custom';

CollectionToolbar takes segments for one row of search plus toggle groups (build them with segmentFilter and segmentSort), and ruled for the sheet rule above it. SplitButton joins a main action and a Menu of other ways to do it.

CollectionCards shows the current page as a grid of cards, for a page that lists things such as Entities or accounts. Give it renderCard (a StatCard works well); CollectionToolbar and CollectionPagination work with it as they do with the table.

<CollectionToolbar collection={collection} searchLabel="Find an Entity" showSort />
<CollectionCards collection={collection} label="Entities" renderCard={(e) => <StatCard title={e.name} onOpen={() => open(e.id)} />} />
<CollectionPagination page={collection.page} pageCount={collection.pageCount} total={collection.pageTotal} onPageChange={collection.setPage} label="Entities" />

useCollection holds search, sort, filters, selection, grouping, paging, and column order and visibility. DataTable and DataList render that same state. Switch views by changing the component. The plain <table class="Table"> is unchanged, for static tables and reports.

Columns decide how a value sorts, searches, filters and totals. rowHeader names the row. One column may use a tabs filter. Money totals add in minor units. Search is deferred, not debounced.

The toolbar keeps the tabs and the search while rows are selected, and swaps the trailing controls for the selection bar, so the bar does not push the search down. The search grows to fill the row beside the filters and actions, and wraps to its own line before it gets narrower than collection.search-min-width. Its icon and its clear button sit inside the field, and that clear button is the only one: the toolbar has no Clear filters button. When nothing matches, the no-results state in the table or list offers Clear filters. A list sorts from the toolbar. A table sorts from its headers. Sort cycles ascending, descending, then back to the system's default.

Keyboard, on a table or a list, when a row can be activated: the row-header buttons are one tab stop. Up and Down move between rows. Home and End go to the first and last row on the page. Space toggles that row's checkbox. Enter activates the row. Escape clears the search when it has text, and so does the clear button inside the field. Arrow keys do nothing while focus is in a field inside a cell. This is a data table, not a grid.

State attributes on the view: data-sorted, data-filtered, data-selecting, data-loading, data-grouped, data-view (table or list), data-size (sm or md).

A tree stays a data table. The row header's accessible name carries the level ("Cash, level 2, in Assets"). It is not a treegrid. ArrowRight opens a closed parent and ArrowLeft closes an open one, swapped in right-to-left. Totals add leaf rows only, so a parent's amount is not added again. A click on [data-no-activate] does not activate the row.

EditableList can take defaultValue with no value. itemLabel names one row ("Allocation"). Pass EditableList.useField(column)'s id and aria-labelledby to the input, including a Select or MoneyField that draws its own input. name writes hidden inputs for primitive fields other than id, or for the keys in fields. Do not combine name with fields the app names itself.

Not built, and the state is ready for the first three: user column show/hide and reorder, row reorder, inline cell editing, virtualization, multi-sort, column resize. EditableList has no move-up or drag yet; those buttons would sit in the row, ahead of any drag handle.