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

@zappar/view-in-ar-button

v1.0.2

Published

Framework-agnostic <view-in-ar-button> custom element for sharing 3D/AR models via QR code and WebRTC.

Readme

@zappar/view-in-ar-button

A framework-agnostic <view-in-ar-button> custom element. It renders a trigger button; clicking it opens a self-contained modal with a QR code, a share link and an optional 6-digit PIN. Behind the modal the element registers itself as a PeerJS host peer, waits for the AR viewer to dial in, and streams your model over the WebRTC data channel.

The host app only has to do three things: put the element on the page, tell it where the AR viewer lives, and hand it a model.

Installation

npm install @zappar/view-in-ar-button

peerjs and qrcode are bundled into dist/view-in-ar-button.js, so there is nothing else to install.

From the CDN

Every release is also served from libs.zappar.com, for a page with no build step:

<script type="module" src="https://libs.zappar.com/view-in-ar-button/1/view-in-ar-button.js"></script>

Three URLs point at each release, and which one you use is a choice about upgrades:

| URL | Serves | Cached | | --- | --- | --- | | …/1/view-in-ar-button.js | the newest 1.x.x | 5 minutes | | …/1.2/view-in-ar-button.js | the newest 1.2.x | 5 minutes | | …/1.2.3/view-in-ar-button.js | exactly that release, forever | immutable |

Pin the exact version in anything you ship to a customer: a floating URL picks up the next patch within five minutes of it being released, which is what you want while developing and not what you want in a campaign that has already been signed off. Pre-releases (1.3.0-rc.1) are published at their exact version only — they never move 1 or 1.3.

Each prefix also carries an info.json naming the version and the commit it was built from.

Usage

Importing the module registers the element and injects the Google Fonts <link> tags for the Material Symbols icons it uses. The import has side effects, so import it for its effect — don't rely on a bundler keeping a bare type-only import.

import '@zappar/view-in-ar-button'
<view-in-ar-button id="ar"></view-in-ar-button>

Then point it at your AR viewer page and give it a model:

const button = document.querySelector('#ar')

button.arViewerUrl = 'https://ar.example.com/viewer'

button.model = {
  file: modelFile,        // a File (or anything File-like) containing the .glb/.usdz
  name: 'red-sneaker',    // optional, arrives as `assetName` on the viewer
}

model is a setter: assign it at any time. If no viewer is connected yet the element holds on to it and sends it the moment the connection opens; assign a new model later and it re-sends. You never have to wait for a statuschange event before setting it.

Customising the trigger

The trigger's content comes from the default slot. Leave it empty for the built-in QR icon and "View in AR" label, or put whatever you like inside:

<view-in-ar-button>
  <img src="/icons/ar.svg" alt="" />
  Try it on
</view-in-ar-button>

Slotted <img> and <svg> are sized to 1.2em and won't shrink.

Mobile trigger

There's a second, named mobile-trigger slot. It's CSS-driven, not conditional on markup: the element always wires a click handler onto it, and the stylesheet is what decides which of the two triggers is actually visible at a given viewport width — the default slot carries .m-hide, mobile-trigger carries .d-hide. Above the breakpoint you get the usual trigger that opens the QR modal; below it, the mobile-trigger slot takes over.

Clicking it skips the modal entirely — it opens link (the same App Clip launcher URL the QR code encodes) in a new tab, since a phone doesn't need to scan a code to reach a URL it's already on:

<view-in-ar-button>
  <span slot="mobile-trigger">
    <img src="/icons/play.svg" alt="" />
    Launch AR
  </span>
</view-in-ar-button>

Leave it empty and you get the built-in play icon. Because both slots render at once and CSS just toggles which is shown, don't rely on one being absent from the DOM to detect viewport — check the breakpoint yourself if you need that.

Opening and closing programmatically

button.open()
button.close()

The modal closes on the close button, a backdrop click, and Esc. It's a native <dialog>, so focus is trapped for you.

Events

button.addEventListener('statuschange', (event) => {
  // 'idle' | 'connecting' | 'open' | 'closed' | 'error'
  console.log(event.detail)
})

button.addEventListener('requirepinchange', (event) => {
  const { requirePin, pin } = event.detail
})

Both bubble and are composed, so you can listen on an ancestor.

React and other frameworks

It's a plain custom element, so it works anywhere. For React, the package ships JSX typings:

/// <reference types="@zappar/view-in-ar-button/react" />

or add @zappar/view-in-ar-button/react to types in your tsconfig.json. Then:

import { useEffect, useRef } from 'react'
import '@zappar/view-in-ar-button'

function ArButton({ file }: { file: File }) {
  const ref = useRef<HTMLElementTagNameMap['view-in-ar-button']>(null)

  useEffect(() => {
    if (ref.current) ref.current.model = { file, name: file.name }
  }, [file])

  return <view-in-ar-button ref={ref} />
}

A ref callback fires synchronously right after React inserts the element, which is early enough to set peerOptions there too — see the timing note below for exactly how much slack you have.

Cleanup

disconnectedCallback closes the connection, destroys the peer and removes the modal from <body>. Removing the element from the DOM is all the teardown there is. The share link and QR code die with it, which is what the in-modal warning is telling the user.

Setting up the peer connection on the host app

The element is the host peer. It generates its own id, registers with a signalling server, publishes the id in the QR code and share link, and waits. The AR viewer is the client peer: it reads the id out of the URL and dials in. Your host app configures where that pairing happens and where the viewer lives.

1. Name your signalling and ICE servers

button.peerOptions = {
  // Signalling: where the two peers exchange SDP/ICE.
  host: 'peer.example.com',
  port: 443,
  secure: true,
  path: '/peerjs',
  key: 'your-key',

  // ICE: how they find a path to each other.
  config: {
    iceServers: [
      { urls: 'stun:stun.example.com:3478' },
      {
        urls: 'turn:turn.example.com:3478',
        username: '…',
        credential: '…',
      },
    ],
  },
}

Do set both, deliberately. Left empty, PeerJS pairs on its public cloud signalling server and substitutes its own default config — which includes public TURN relays. TURN relays carry every byte of the data channel, which for this component means your model file passing through a third party's server. If you care where model data goes, name config yourself. STUN-only is the cheapest option and works on most networks; a TURN server of your own is the fallback for symmetric NATs and restrictive corporate networks.

If your signalling server namespaces ids per product, prefix them:

button.peerIdPrefix = 'myapp-'

The rest of the id is always a crypto.randomUUID(). Be clear-eyed about what that means: the unguessable id is the entire access control on a session. Anyone holding the link can connect. The PIN toggle is a second factor on top, not a replacement, and enforcing it is the viewer's job (see step 3).

A note on timing

The peer doesn't register the instant the element is inserted — connectedCallback defers it to a microtask, specifically so a same-tick assignment still lands. That covers the common cases:

// Plain DOM: configure before or right after inserting, doesn't matter which.
const button = document.createElement('view-in-ar-button')
document.body.append(button)
button.peerOptions = { /* … */ }     // still in time
button.peerIdPrefix = 'myapp-'
// React: a ref callback fires synchronously right after insertion — same tick.
function ArButton() {
  const ref = useRef<HTMLElementTagNameMap['view-in-ar-button']>(null)
  return (
    <view-in-ar-button
      ref={(el) => {
        if (el) el.peerOptions = { /* … */ }   // still in time
      }}
    />
  )
}

What's not in time is assigning peerOptions from something that runs after that tick has finished — a useEffect that fires on a later macrotask, an await before the assignment, a setTimeout, and so on. The peer will already be registering with whatever peerOptions held at that point (its {} default, if nothing was set). If your value isn't available synchronously, hold the element out of the document until it is:

const button = document.createElement('view-in-ar-button')
const peerOptions = await loadPeerOptions()
button.peerOptions = peerOptions
document.querySelector('#toolbar').append(button)   // peer starts here, with the right value

or subclass to bake defaults in before connectedCallback can ever run:

import { ViewInArButtonElement } from '@zappar/view-in-ar-button'

class MyArButton extends ViewInArButtonElement {
  constructor() {
    super()
    this.peerOptions = { /* … */ }
    this.peerIdPrefix = 'myapp-'
  }
}
customElements.define('my-ar-button', MyArButton)

arViewerUrl has no such constraint — it's read each time the link is built, so it can change at any time, even after the peer is connected.

2. Point at the AR viewer

button.arViewerUrl = 'https://ar.example.com/viewer'   // or '/viewer', or '/viewer?lang=fr'

Anything URL accepts, resolved against the current document. The peer id is added with searchParams.set, so an existing query string or hash survives.

The default is the current document, which is only correct when one page is both host and viewer. Name the viewer explicitly otherwise — the iOS App Clip is registered per domain, so a viewer on the wrong hostname won't launch it.

What actually ends up in the QR code is an App Clip launcher URL wrapping your viewer:

https://e.webxr.run/?url=https%3A%2F%2Far.example.com%2Fviewer%3Fpeer%3Dmyapp-<uuid>

On iOS that offers to open the Zappar App Clip; on Android it navigates straight through to your viewer. Read button.link if you want to surface it elsewhere.

3. Dial in from the viewer

The viewer page reads ?peer= and connects as a client. Nothing in this package runs on the viewer side, so this is yours to write — with peerjs and the same peerOptions:

import { Peer } from 'peerjs'

const peerOptions = { /* identical to the host's */ }

const hostId = new URLSearchParams(location.search).get('peer')
if (!hostId) throw new Error('No peer id in URL')

const peer = new Peer(peerOptions)          // let the server assign the client id

peer.on('open', () => {
  const connection = peer.connect(hostId, { reliable: true })

  connection.on('open', () => {
    // Host flips to 'open' here and sends the model immediately.
  })

  connection.on('data', (data) => {
    const { model, isRequirePin, pin, assetName } = data
    // `model` is an ArrayBuffer of the file the host assigned.
    if (isRequirePin && promptForPin() !== pin) return   // enforce the PIN here
    render(new Blob([model]), assetName)
  })

  connection.on('error', console.error)
})

Two things to keep in mind:

  • data can fire more than once. The host re-sends whenever model is reassigned, and whenever the PIN toggle changes. Treat each message as the current state of the session, not a one-shot handshake.
  • The PIN is advisory unless you check it. The host puts isRequirePin and pin in the payload; only the viewer can refuse to render. Note that the PIN travels with the model, so it gates display, not delivery.

4. Track the connection in your UI

button.addEventListener('statuschange', ({ detail }) => {
  switch (detail) {
    case 'connecting': showSpinner(); break
    case 'open':       showConnected(); break
    case 'closed':     showDisconnected(); break
    case 'error':      showError(); break   // also logged to the console
  }
})

Only one viewer at a time: an incoming connection closes the previous one.

Sending your own payloads

send() is there if you want to push something outside the automatic model flow — the same ArViewerData shape, and a no-op if nothing is connected:

if (button.status === 'open') {
  button.send({
    model: buffer,
    isRequirePin: false,
    assetName: 'variant-b',
  })
}

Customizing & Theming {#theming}

The element's internals live in a shadow root, so your page's class-based CSS can't reach them. Styling is done with CSS custom properties instead, which inherit across shadow boundaries. Every token has a built-in fallback, so the component looks correct with no theming at all — define only the tokens you want to change.

Set them on :root (or any ancestor of the element):

:root {
  --brand-primary: #0b5fff;
  --brand-primary-light: #3d81ff;
  --radius-pill: 6px;
  --sans: 'Inter', system-ui, sans-serif;
}

Token reference

Brand

| Token | Fallback | Used by | | --- | --- | --- | | --brand-primary | #EF5332 | Trigger background, close button, focus ring | | --brand-primary-light | color-mix(--brand-primary 80%, white) | Trigger hover | | --brand-primary-gradient-linear | #f2f2f2 | QR panel background | | --brand-primary-surface | #ffffff | Field group background (mondelez theme) |

Colour

| Token | Fallback | Used by | | --- | --- | --- | | --white | #ffffff | Modal, inputs, trigger text, toggle thumb | | --color-ink | #073158 | Body text, labels, download button border | | --color-surface-subtle | #f2f2f2 | Hover backgrounds | | --color-input-stroke | #b2c4d7 | Input borders | | --color-warning | #feb34b | Alert top border and icon | | --color-warning-border | rgba(254, 179, 75, 0.5) | Alert border | | --color-warning-surface | rgba(254, 179, 75, 0.1) | Alert background | | --color-disabled | #CCCCCC | Toggle, off | | --color-toggle-on | #4a90e2 | Toggle, on | | --modal-backdrop | rgba(0, 0, 0, 0.6) | ::backdrop |

Typography

| Token | Fallback | | --- | --- | | --sans | 'Open Sans', system-ui, 'Segoe UI', Roboto, sans-serif | | --font-size-sm | 14px | | --font-size-base | 16px | | --font-size-xl | 24px |

Spacing, radius, borders

| Token | Fallback | | --- | --- | | --spacing-xxs | 4px | | --spacing-xs | 8px | | --spacing-sm | 12px | | --spacing-md | 16px | | --spacing-lg | 24px | | --spacing-xl | 32px | | --radius-default | 8px | | --radius-lg | 16px | | --radius-pill | 999px | | --border-width-default | 1px |

Named themes

The stylesheet carries variants keyed off a data-model-viewer-theme ancestor, currently mondelez, which flattens the alert, rounds the download button, and boxes the field groups:

<div data-model-viewer-theme="mondelez">
  <view-in-ar-button></view-in-ar-button>
</div>

You can also put the attribute on the element itself. That case is worth understanding: the modal is rendered into its own shadow root appended straight to <body>, not inside the trigger's shadow tree, so that position: fixed stays anchored to the viewport even when an ancestor has a transform. Because the modal host sits outside your markup, it can't see a [data-model-viewer-theme] ancestor of yours — the element mirrors the attribute from itself onto the modal host for you. So:

  • attribute on an ancestor → theme applies to the trigger; it reaches the modal only if that ancestor is an ancestor of <body>, i.e. <html>.
  • attribute on the element itself → applies to both, reliably. Prefer this, or set it on <html>.

For the same reason, CSS custom properties intended for the modal must be defined on :root/html/body rather than on a wrapper <div>.

Styling the trigger directly

The trigger button exposes a shadow part:

view-in-ar-button::part(trigger) {
  box-shadow: 0 2px 8px rgb(0 0 0 / 0.15);
  text-transform: uppercase;
}

There is no part on the modal's internals — use the tokens there.

Icon font

Icons come from Material Symbols Rounded, loaded from Google Fonts on first registration (duplicate-guarded). If your CSP blocks fonts.googleapis.com / fonts.gstatic.com, allow them or self-host the family under the same name; the icon glyphs will otherwise render as their ligature text (qr_code, close, …).

Examples {#examples}

Each of these is a complete, runnable starting point — fill in your own host/key/arViewerUrl.

Vanilla JS: file picker → AR

<input type="file" id="file" accept=".glb,.usdz" />
<view-in-ar-button id="ar"></view-in-ar-button>

<script type="module">
  import '@zappar/view-in-ar-button'

  const button = document.querySelector('#ar')

  button.arViewerUrl = 'https://ar.example.com/viewer'
  button.peerOptions = {
    host: 'peer.example.com',
    port: 443,
    secure: true,
    path: '/peerjs',
    key: 'your-key',
    config: {
      iceServers: [{ urls: 'stun:stun.example.com:3478' }],
    },
  }

  button.addEventListener('statuschange', ({ detail }) => {
    console.log('viewer connection:', detail)
  })

  document.querySelector('#file').addEventListener('change', (event) => {
    const file = event.target.files[0]
    if (file) button.model = { file, name: file.name }
  })
</script>

React: swapping between product variants

Configuring peerOptions in the ref callback works because it runs synchronously right after insertion — see the timing note if you need to load that config asynchronously instead.

import { useEffect, useRef, useState } from 'react'
import '@zappar/view-in-ar-button'

type Variant = { file: File; label: string }
type ArButtonEl = HTMLElementTagNameMap['view-in-ar-button']

const PEER_OPTIONS = {
  host: 'peer.example.com',
  port: 443,
  secure: true,
  path: '/peerjs',
  key: 'your-key',
}

function ProductArButton({ variants }: { variants: Variant[] }) {
  const [status, setStatus] = useState<ArButtonEl['status']>('idle')
  const buttonRef = useRef<ArButtonEl | null>(null)

  useEffect(() => {
    const button = buttonRef.current
    if (!button) return
    const onStatusChange = (e: Event) => setStatus((e as CustomEvent).detail)
    button.addEventListener('statuschange', onStatusChange)
    return () => button.removeEventListener('statuschange', onStatusChange)
  }, [])

  return (
    <>
      <view-in-ar-button
        ref={(el: ArButtonEl | null) => {
          buttonRef.current = el
          if (el) {
            el.peerOptions = PEER_OPTIONS
            el.arViewerUrl = 'https://ar.example.com/viewer'
          }
        }}
      />
      <p>Status: {status}</p>
      {variants.map((variant) => (
        <button
          key={variant.label}
          onClick={() => {
            if (buttonRef.current) buttonRef.current.model = variant
          }}
        >
          {variant.label}
        </button>
      ))}
    </>
  )
}

Custom trigger + a themed modal

Combines a slotted trigger with theme tokens scoped to one instance:

<style>
  .promo-ar {
    --brand-primary: #0b5fff;
    --brand-primary-light: #3d81ff;
    --radius-pill: 6px;
    --sans: 'Inter', system-ui, sans-serif;
  }
</style>

<div class="promo-ar" data-model-viewer-theme="promo">
  <view-in-ar-button id="ar">
    <img src="/icons/ar.svg" alt="" />
    See it in your space
  </view-in-ar-button>
</div>

Note the tokens are on .promo-ar, an ancestor of the trigger — that's fine for --brand-primary etc., but data-model-viewer-theme still needs to be readable from <body> for the modal to pick up the promo variant, since the modal is appended straight to <body>, outside .promo-ar. Put the attribute on <html> instead if the modal itself needs theming, or on #ar directly.

Full host ↔ viewer pairing

The snippets above only cover the host side. For the matching viewer page — reading ?peer= off the URL, connecting with peerjs, and enforcing the PIN — see Setting up the peer connection on the host app, step 3.

API

Properties

| Property | Type | Default | Notes | | --- | --- | --- | --- | | arViewerUrl | string | current document URL | Page the QR code and link open, with ?peer=<id> appended. Read every time the link is built. | | model | ArModel \| null | null | { file, name? }. Sent automatically once connected, and re-sent on reassignment. | | peerOptions | PeerJSOption | {} | Signalling server + ICE servers. Read once, deferred to a microtask after connection so a same-tick assignment (e.g. a React ref callback) still applies — see timing note. | | peerIdPrefix | string | '' | Prefix for the generated peer id. | | pin | string \| null | null | Current PIN; regenerated whenever the toggle is turned on. | | link | string (readonly) | — | The App Clip launcher URL encoded in the QR code. | | status | WebRtcStatus (readonly) | 'idle' | idle / connecting / open / closed / error. |

Methods

| Method | Notes | | --- | --- | | open() | Opens the modal. | | close() | Closes the modal (300 ms fade). | | send(data: ArViewerData) | Pushes a payload to the viewer. No-op unless the connection is open. |

Attributes

| Attribute | Notes | | --- | --- | | data-model-viewer-theme | Observed. Mirrored onto the modal's host so theme selectors apply there too. |

Types

interface ArModel {
  file: File
  name?: string
}

type ArViewerData = {
  model: ArrayBuffer
  isRequirePin: boolean
  pin?: string
  assetName?: string
}

interface RequirePinChangeDetail {
  requirePin: boolean
  pin: string | null
}

type WebRtcStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error'

Browser support

Needs shadow DOM, <dialog> with showModal, crypto.randomUUID and WebRTC data channels — evergreen Chrome, Edge, Firefox and Safari 15.4+. crypto.randomUUID requires a secure context, so serve over HTTPS (or localhost). Below 720px the modal stacks the QR panel above the content. Copy-to-clipboard degrades quietly where navigator.clipboard is unavailable; the link and PIN stay selectable.

License

UNLICENSED — internal use.

Releasing

A release is a tag. .gitlab-ci.yml builds the package once and publishes those same bytes to both places:

# from a clean main, with the version already bumped in package.json if you like —
# the tag is what CI actually publishes as
git tag v1.2.3
git push origin v1.2.3

package:build builds dist/; npm:publish stamps the version from the tag and publishes to npm; cdn:publish uploads the same dist/ to libs.zappar.com with upload.sh. The CDN job runs after npm on purpose — npm refuses to publish a version that already exists, and that refusal is what stops a re-pushed tag from rewriting a CDN path that is served as immutable.

Neither job needs a secret. npm authenticates by trusted publishing and AWS by GitLab OIDC, and both are keyed to a v* tag of this project, so nothing else can publish — see infra/lib/package-publish-stack.ts for the role.

Two things have to be in place before the first release, both one-offs:

  1. The npm package must exist, with a trusted publisher configured. npm can only be told to trust a publisher for a package it already has, so 1.0.0 (or whatever the first version is) has to be published by hand, by someone in the @zappar org, from a clean checkout: npm ci && npm publish. Then, under the package's Settings → Trusted publisher on npmjs.com, add this GitLab project (zappar/zapcode-creator/3d-model-viewer) with workflow path .gitlab-ci.yml. Every release after that is a tag and nothing else.
  2. The publishing role must be deployednpm run deploy:publish-role in infra/, once, with credentials for the account holding libs.zappar.com. See "Publishing the package" in infra/README.md.

upload.sh can be run by hand to repair a release, with VERSION=1.2.3 ./upload.sh and credentials for that bucket, but overwriting an exact version leaves browsers holding the old bytes for a year. Publishing a new patch is almost always the right repair.