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

@apostel/bedrock

v0.1.0

Published

Headless React primitives built on native platform features

Downloads

0

Readme

bedrock

Headless React primitives built on the browser instead of on top of it.

Radix-shaped anatomy — compound components, asChild, the same part names you already type. Different foundation: the top layer, invoker commands, anchor positioning, and @starting-style do the work that Portal, Presence, DismissableLayer, and Floating UI used to do.

npm i @apostel/bedrock

Docs, and a compat table that tests the browser you open it in: https://bedrock.sams.land

The shape

import { Dialog } from '@apostel/bedrock'

<Dialog.Root>
  <Dialog.Trigger>Delete project</Dialog.Trigger>
  <Dialog.Content>
    <Dialog.Title>Delete project?</Dialog.Title>
    <Dialog.Description>This cannot be undone.</Dialog.Description>
    <Dialog.Close>Cancel</Dialog.Close>
    <button onClick={destroy}>Delete</button>
  </Dialog.Content>
</Dialog.Root>

That renders roughly:

<button commandfor="d1" command="show-modal">Delete project</button>
<dialog id="d1">…</dialog>

No effect opens it. No state hook holds it. No portal moves it. The button is wired to the dialog by the parser, so it works before hydration and keeps working if your JS bundle fails to load.

Two roots

The DOM owns open state. Most of the time that's all you need, so that's the default — and it costs nothing.

Dialog.Root — the DOM opens and closes itself. Accepts defaultOpen and a read-only onOpenChange, which is a toggle listener and nothing more. This covers the common case that gets miscategorised as "controlled": I need to know when it closed so I can reset the form.

Dialog.Root from @apostel/bedrock/controlled — React gets a veto. Your open prop decides whether an open is allowed to proceed.

import { Dialog } from '@apostel/bedrock/controlled'

<Dialog.Root open={open} onOpenChange={setOpen}>
  {/* identical children */}
</Dialog.Root>

Same component names, same parts, same props on every part. Swapping is a one-line import change, and a codemod if you're doing it across a repo.

The two entry points are separate module graphs, so if nothing in your app imports /controlled, none of the reconciliation code reaches your bundle. This is a real guarantee from the exports map, not a tree-shaking hope.

How the veto works

Controlled mode is DOM leads, React vetoes, not React-owns-state:

  1. User clicks. The browser fires beforetoggle, which is cancelable for popovers. If your open prop disagrees, we preventDefault() and nothing moved.
  2. onOpenChange fires either way, so you can decide.
  3. If your prop changes without user interaction, a reconciliation effect moves the DOM to match.

Where the platform gives no cancelable hook, step 1 is skipped and step 3 puts things back — one frame of visible movement in the refuse case only. Modal dialogs get an extra veto on close via request-close and the cancel event.

asChild and the button rule

Invoker commands work on <button> only. Interest invokers also work on <a>. So this is fine:

<Tooltip.Trigger asChild><a href="/pricing">Pricing</a></Tooltip.Trigger>

and this is not:

<Dialog.Trigger asChild><div onClick={…}>Open</div></Dialog.Trigger>

A trigger that isn't a button gets no commandfor wiring, which means no keyboard activation and no implicit aria-expanded. That's an accessibility regression, and it's silent, so we make noise about it: development throws at mount, with the tag it found and the tags that part accepts. Production logs an error at the same check point.

There is no fallback click handler. Attaching one would ship the exact imperative trigger machinery this library exists to delete, to every consumer, forever — and nobody removes a fallback that works.

If you're wrapping a third-party component that renders a div and can't change it, take the props and own the consequences:

const props = useDialogTrigger(id)
<ThirdPartyThing {...props} />

The same check catches <button> inside a <form> without type="button". Submit buttons can't invoke popovers — submitting and opening are conflicting behaviours — and that one bites people more often than the div case.

What's native and what still ships JS

Be clear-eyed about this. The platform deleted positioning and layering. It did not delete focus management.

Almost entirely native — AspectRatio, Separator, Label, VisuallyHidden, Progress, Slider, ScrollArea, Collapsible, Accordion, Checkbox, Switch, RadioGroup, Select, Dialog, AlertDialog, Popover.

Several of those are one element and no state at all: Progress is <progress>, Slider is <input type="range">, Select is a <select> under appearance: base-select, and Accordion gets single-open exclusivity from <details name> rather than from an effect that closes its siblings.

Native layering, small JS state layer — Tooltip, HoverCard, Avatar, Toggle, Toast.

Still substantially JS — DropdownMenu, ContextMenu, Menubar, NavigationMenu, Tabs, Toolbar, ToggleGroup.

The reason is short: there is no native roving tabindex, no native typeahead, no focus trap for non-modal layers, and no way to anchor to a pointer coordinate. Every menu-shaped primitive needs all four, and they all share one roving.ts — which ships from either entry point, so the two-root split saves them almost nothing. The docs say that rather than implying otherwise.

Measured, not estimated — esbuild, minified, gzipped, React external:

| primitive | bedrock | Radix | | | --- | --- | --- | --- | | Checkbox | 0.70 kB | 5.9 kB | 8.5× | | Slider | 0.74 kB | 9.8 kB | 13× | | Select | 0.84 kB | 31.5 kB | 37× | | Accordion | 1.61 kB | 8.8 kB | 5.5× | | Dialog | 1.95 kB | 13.7 kB | 7× | | Popover | 2.11 kB | 24.2 kB | 11× | | Tooltip | 2.55 kB | 19.3 kB | 7.6× | | DropdownMenu | 3.55 kB | 31.6 kB | 8.9× |

The ratio narrows as the roving module gets involved and widens where the platform has a whole element to hand over. The bigger win is behavioural — no z-index fights, no portal-and-clipping bugs, no positioning recalculation on every scroll frame.

Browser support

Latest Chrome, deliberately, for now.

appearance: base-select, interpolate-size, ::scroll-marker, and popover=hint are shipped or shipping across engines, so that bet is about timing. interestfor is the one that isn't standardised yet, and it's what drives Tooltip and HoverCard triggers.

Because of that, no platform feature appears in a public prop name. Tooltip.Root takes delayDuration; today it becomes interest-show-delay, tomorrow it might become a timer. Feature detection lives in one module, every non-Baseline CSS block sits behind @supports, and the implementation can be swapped without a major version.

Styling

Ship bedrock.css or don't — every part takes className and renders one element. Open and closed states are selectable with :open and :popover-open; enter and exit animation is @starting-style plus transition-behavior: allow-discrete, no forceMount, no presence wrapper.

Docs

docs/ — getting started, the Dialog API, styling, the migration guide, and a shadcn registry that swaps Radix for bedrock in shadcn's own components.

docs/compat.html — live at https://bedrock.sams.land/compat.html — tests every platform feature bedrock uses in whatever browser you open it in, and says what each missing one does to your UI. Chrome 141 scores 20 of 21.

Migrating with an agent: skills/migrate-to-bedrock, a Claude Code skill that does the mechanical work and brings you the four decisions it cannot make for you.

Contributing is CONTRIBUTING.md; releasing is RELEASING.md.

docs/gaps.md is the case against adopting this. Read it first if you are evaluating.

Status

Pre-alpha, but no longer a list: all 29 primitives exist, with 97 Playwright tests against real Chrome. What is missing is soak time, other engines, and the things named in gaps — not components.