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

@bsky.app/tapper

v0.3.0

Published

A minimal rich text editor for React Native and web.

Readme

@bsky.app/tapper

A facet-aware text input engine for React Native (including web). Parses configurable patterns (mentions, emoji, tags, URLs) from text as the user types, producing a node list you render as a colored preview overlay on top of a transparent TextInput.

Installation

pnpm add @bsky.app/tapper

Peer dependencies: react, react-native.

Basic example

import {useEffect} from 'react'
import {View, Text, TextInput} from 'react-native'
import {useTapper} from '@bsky.app/tapper'

function Composer() {
  const {text, nodes, inputProps, focus, on} = useTapper({
    facets: {
      mention: /@([a-zA-Z0-9._]+)/g,
      emoji: /:([a-zA-Z0-9_]+):/g,
    },
  })

  // Re-focus after an autocomplete insertion
  useEffect(() => {
    return on('afterInsert', () => focus())
  }, [on, focus])

  return (
    <View style={{position: 'relative'}}>
      {/* Preview overlay — renders colored facets */}
      <View
        style={{position: 'absolute', top: 0, left: 0, right: 0, bottom: 0}}>
        <Text>
          {nodes.map((node, i) => (
            <Text
              key={i}
              style={{
                color:
                  node.type === 'facet'
                    ? '#2e7de9'
                    : node.type === 'trigger'
                      ? '#999'
                      : '#000',
              }}>
              {node.raw}
            </Text>
          ))}
        </Text>
      </View>

      {/* Actual input — transparent text, visible caret */}
      <TextInput
        {...inputProps}
        multiline
        style={{
          color: 'transparent',
          caretColor: '#000',
        }}
        placeholder="Type here..."
      />
    </View>
  )
}

The preview overlay and TextInput must share the same text styles (font family, size, line height, padding) so they align exactly.

How it works

useTapper returns:

| Property | Description | | ------------- | --------------------------------------------------- | | text | Current text value | | nodes | Parsed node list for rendering | | activeFacet | The facet the cursor is currently inside, or null | | inputProps | Spread onto your TextInput | | on | Subscribe to events | | focus | Re-focus the input (useful after commit) |

Nodes

Each node has a type discriminant:

  • 'text' — plain text, render as-is.
  • 'trigger' — the user just typed a trigger character (e.g. @) but hasn't typed any content yet. Has facetType so you know which kind.
  • 'facet' — a matched pattern. Has facetType (e.g. 'mention', 'emoji').

All nodes have raw (the full matched text including trigger) and value (content only, trigger stripped). For display, use node.raw.

// Typing "@eric" produces:
{type: 'facet', facetType: 'mention', raw: '@eric', value: 'eric', start: 0, end: 5}

Active facet

When the cursor is inside a facet or right after a trigger character, activeFacet is non-null:

activeFacet: {
  type: 'mention',       // the facet type
  value: 'er',           // content after trigger (no @)
  range: {start: 0, end: 3},
  insert: (value, options?) => void,
}

Call activeFacet.insert('@eric') to replace the in-progress facet with a final value. A trailing space is appended by default; pass {noTrailingSpace: true} to suppress it.

Emoji auto-commit example

A common pattern: when the user types a closing : on an emoji shortcode (e.g. :wave:), immediately replace it with the emoji character.

const {on, activeFacet, focus} = useTapper({
  facets: {
    emoji: /:([a-zA-Z0-9_]+):/g,
  },
})

const EMOJI: Record<string, string> = {
  wave: '👋',
  tada: '🎉',
  v: '✌️',
}

useEffect(() => {
  return on('activeFacet', facet => {
    if (facet?.type === 'emoji' && facet.value.endsWith(':')) {
      const name = facet.value.slice(0, -1) // strip closing ":"
      const emoji = EMOJI[name]
      if (emoji) {
        facet.insert(emoji, {noTrailingSpace: true})
      }
    }
  })
}, [on])

// Re-focus after insertion
useEffect(() => {
  return on('afterInsert', () => focus())
}, [on, focus])

Events

Subscribe with on(event, callback). Returns an unsubscribe function — use it in a useEffect cleanup.

| Event | Data | When | | ---------------- | --------------------------- | ----------------------------------------------- | | activeFacet | TapperActiveFacet \| null | Cursor enters or leaves a facet | | facetCommitted | TapperFacet | A facet is finalized (cursor left or committed) | | afterInsert | TapperFacet | After insert() replaces text |

focus()

After insert() replaces text, the input may lose focus on some platforms. Listen for afterInsert and call focus() to restore it:

useEffect(() => {
  return on('afterInsert', () => focus())
}, [on, focus])

Atomic deletion

Committed facets are deleted atomically — backspacing into a committed facet from outside removes the entire facet in one keystroke, rather than deleting character by character.

initialText

Pre-populate the input with text. Any matched facets are automatically marked as committed.

const {nodes} = useTapper({
  facets: {mention: /@([a-zA-Z0-9._]+)/g},
  initialText: 'Hello @eric',
})

replaceText

The Tapper class (used internally by useTapper) exposes replaceText(text, cursor?) for programmatic text replacement. All matched facets in the new text are marked as committed.