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 🙏

© 2025 – Pkg Stats / Ryan Hefner

@remix-run/interaction

v0.3.0

Published

Like components but for events

Readme

interaction

Enhanced events and custom interactions for any EventTarget.

Features

  • Declarative Bindings - Event bindings with plain objects
  • Semantic Interactions - Reusable "interactions" like longPress and arrowDown
  • Async Support - Listeners with reentry protection via AbortSignal
  • Type Safety - Type-safe listeners and custom EventTarget subclasses with TypedEventTarget

Installation

npm install @remix-run/interaction

Getting Started

Adding event listeners

Use on(target, listeners) to add one or more listeners. Each listener receives (event, signal) where signal is aborted on reentry.

import { on } from '@remix-run/interaction'

let inputElement = document.createElement('input')

on(inputElement, {
  input: (event, signal) => {
    console.log('current value', event.currentTarget.value)
  },
})

Listeners can be arrays. They run in order and preserve normal DOM semantics (including stopImmediatePropagation).

import { on } from '@remix-run/interaction'

on(inputElement, {
  input: [
    (event) => {
      console.log('first')
    },
    {
      capture: true,
      listener(event) {
        // capture phase
      },
    },
    {
      once: true,
      listener(event) {
        console.log('only once')
      },
    },
  ],
})

Built-in Interactions

Builtin interactions are higher‑level, semantic event types (e.g., press, longPress, arrow keys) exported as string constants. Consume them just like native events by using computed keys in your listener map. When you bind one, the necessary underlying host events are set up automatically.

import { on } from '@remix-run/interaction'
import { press, longPress } from '@remix-run/interaction/press'

on(listItem, {
  [press](event) {
    navigateTo(listItem.href)
  },

  [longPress](event) {
    event.preventDefault() // prevents `press`
    showActions()
  },
})

Import builtins from their modules (for example, @remix-run/interaction/press, @remix-run/interaction/keys). Some interactions may coordinate with others (for example, calling event.preventDefault() in one listener can prevent a related interaction from firing).

You can also create your own interactions.

Async listeners and reentry protection

The signal is aborted when the same listener is re-entered (for example, a user types quickly and triggers input repeatedly). Pass it to async APIs or check it manually to avoid stale work.

on(inputElement, {
  async input(event, signal) {
    showSearchSpinner()

    // Abortable fetch
    let res = await fetch(`/search?q=${event.currentTarget.value}`, { signal })
    let results = await res.json()
    updateResults(results)
  },
})

For APIs that don't accept a signal:

on(inputElement, {
  async input(event, signal) {
    showSearchSpinner()
    let results = await someSearch(event.currentTarget.value)
    if (signal.aborted) return
    updateResults(results)
  },
})

Event listener options

All DOM AddEventListenerOptions are supported via descriptors:

import { on } from '@remix-run/interaction'

on(button, {
  click: {
    capture: true,
    listener(event) {
      console.log('capture phase')
    },
  },
  focus: {
    once: true,
    listener(event) {
      console.log('focused once')
    },
  },
})

Updating listeners efficiently

Use createContainer when you need to update listeners in place (e.g., in a component system). The container diffs and updates existing bindings without unnecessary removeEventListener/addEventListener churn.

import { createContainer } from '@remix-run/interaction'

let container = createContainer(form)

let formData = new FormData()

container.set({
  change(event) {
    formData = new FormData(event.currentTarget)
  },
  async submit(event, signal) {
    event.preventDefault()
    await fetch('/save', { method: 'POST', body: formData, signal })
  },
})

// later – only the minimal necessary changes are rebound
container.set({
  change(event) {
    console.log('different listener')
  },
  submit(event, signal) {
    console.log('different listener')
  },
})

Disposing listeners

on returns a dispose function. Containers expose dispose(). You can also pass an external AbortSignal.

import { on, createContainer } from '@remix-run/interaction'

// Using the function returned from on()
let dispose = on(button, { click: () => {} })
dispose()

// Containers
let container = createContainer(window)
container.set({ resize: () => {} })
container.dispose()

// Use a signal
let eventsController = new AbortController()
let container = createContainer(window, {
  signal: eventsController.signal,
})
container.set({ resize: () => {} })
eventsController.abort()

Stop propagation semantics

All DOM semantics are preserved.

on(button, {
  click: [
    (event) => {
      event.stopImmediatePropagation()
    },
    () => {
      // not called
    },
  ],
})

Custom Interactions

Define semantic interactions that can dispatch custom events and be reused declaratively.

import { defineInteraction, on, type Interaction } from '@remix-run/interaction'

// Provide type safety for consumers
declare global {
  interface HTMLElementEventMap {
    [keydownEnter]: KeyboardEvent
  }
}

function KeydownEnter(this: Interaction) {
  if (!(this.target instanceof HTMLElement)) return

  this.on(this.target, {
    keydown(event) {
      if (event.key === 'Enter') {
        this.target.dispatchEvent(new KeyboardEvent(keydownEnter, { key: 'Enter' }))
      }
    },
  })
}

// define the interaction type and setup function
const keydownEnter = defineInteraction('keydown:enter', KeydownEnter)

// usage
let button = document.createElement('button')
on(button, {
  [keydownEnter](event) {
    console.log('Enter key pressed')
  },
})

Notes:

  • An interaction is initialized at most once per target, even if multiple listeners bind the same interaction type.

Typed Event Targets

Use TypedEventTarget<eventMap> to get type-safe addEventListener and integrate with this library's on helpers.

import { TypedEventTarget, on } from '@remix-run/interaction'

interface DrummerEventMap {
  kick: DrummerEvent
  snare: DrummerEvent
  hat: DrummerEvent
}

class DrummerEvent extends Event {
  constructor(type: DrummerEvent['type']) {
    super(type)
  }
}

class Drummer extends TypedEventTarget<DrummerEventMap> {
  kick() {
    // ...
    this.dispatchEvent(new DrummerEvent('kick'))
  }
}

let drummer = new Drummer()

// native API is typed
drummer.addEventListener('kick', (event) => {
  // event is DrummerEvent
})

// type safe with on()
on(drummer, {
  kick: (event) => {
    // event is Dispatched<DrummerEvent, Drummer>
  },
})

Demos

To run the demos:

pnpm run demos

The demos directory contains working demos:

License

See LICENSE