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

@trendr/core

v0.2.2

Published

direct-mode TUI renderer with JSX, signals, and per-cell diffing

Readme

JSX components, signals, per-cell diffing and flexbox without React and Yoga. Terminals are character grids, not DOM trees. Why reconcile a virtual DOM to write escape sequences?

4-16x faster frame times and 580x less I/O per render than popular TUI frameworks. No dependencies. benchmarks

https://github.com/user-attachments/assets/6e84bb4b-a99e-46f2-a235-1ee4be62c0ae

Usage

npm i @trendr/core

Requires esbuild (or similar) for JSX transformation.

{ "jsx": "automatic", "jsxImportSource": "trend" }
import { mount, createSignal, useInput } from '@trendr/core'

function App() {
  const [count, setCount] = createSignal(0)

  useInput(({ key }) => {
    if (key === 'up') setCount(c => c + 1)
    if (key === 'down') setCount(c => c - 1)
  })

  return (
    <box style={{ flexDirection: 'column', padding: 1 }}>
      <text style={{ color: 'cyan', bold: true }}>Count: {count()}</text>
      <text style={{ color: 'gray' }}>up/down to change</text>
    </box>
  )
}

mount(App)

mount(Component, { stream?, stdin?, title?, theme? }) enters alt screen and returns { unmount, repaint }. Renders on demand when signals change, capped at 60fps.

Theming

Pass a theme object to mount to configure global defaults:

mount(App, {
  theme: {
    accent: 'green',        // focus/highlight color, default 'cyan'
    cursor: {
      blink: true,          // default false
      rate: 530,            // blink interval ms, default 530
      style: 'block',       // default 'block'
      bg: 'cyan',           // cursor background color
      color: 'black',       // cursor text color
    },
  },
})

Components read the theme with useTheme():

import { useTheme } from '@trendr/core'

const { accent } = useTheme()

Individual components still accept explicit color props (e.g. <Spinner color="magenta" />) which override the theme.

Signals

import { createSignal, createEffect, createMemo, batch, untrack, onCleanup } from '@trendr/core'

const [value, setValue] = createSignal(0)
value()         // read (tracks dependency)
setValue(1)     // write
setValue(v => v + 1) // updater

createEffect(() => {
  console.log(value()) // re-runs when value changes
  return () => {}      // optional cleanup
})

const doubled = createMemo(() => value() * 2) // cached derived value

batch(() => {        // coalesce multiple updates into one render
  setValue(1)
  setValue(2)
})

untrack(() => value()) // read without tracking
onCleanup(() => {})    // runs when component unmounts or effect re-runs

Layout

Two element types: box (container) and text (leaf).

<box style={{
  flexDirection: 'column',  // 'column' (default) | 'row'
  flexGrow: 1,              // fill remaining space
  gap: 1,                   // space between children
  justifyContent: 'flex-start', // 'flex-start' | 'center' | 'flex-end'
  alignItems: 'stretch',    // 'stretch' | 'flex-start' | 'center' | 'flex-end'
  width: 20,                // fixed or '50%'
  height: 10,               // fixed or '25%'
  minWidth: 5, maxWidth: 30,
  minHeight: 2, maxHeight: 15,
  padding: 1,               // all sides
  paddingX: 1, paddingY: 1, // axis
  paddingTop: 1, paddingBottom: 1, paddingLeft: 1, paddingRight: 1,
  margin: 1,                // same variants as padding
  border: 'round',          // 'single' | 'double' | 'round' | 'bold'
  borderColor: 'cyan',
  borderEdges: { bottom: true, left: true }, // render only specific sides
  bg: 'blue',               // background color
  texture: 'dots',          // background texture (see below)
  textureColor: '#333',     // color for texture characters
  position: 'absolute',     // remove from flow, position with top/left/right/bottom
  top: 0, left: 0, right: 0, bottom: 0,
  overflow: 'scroll',       // scrollable container (see ScrollBox)
  scrollOffset: 0,          // scroll position (rows from top)
}}>
<text style={{
  color: 'cyan',            // named, hex (#ff0000), or 256-color index
  bg: 'black',
  bold: true, dim: true, italic: true,
  underline: true, inverse: true, strikethrough: true,
  overflow: 'wrap',         // 'wrap' (default) | 'truncate' | 'nowrap'
}}>

Background Textures

Repeating character fill for box backgrounds. Works with or without bg.

<box style={{ bg: '#1a1a2e', texture: 'dots', textureColor: '#2a2a4e' }}>

Presets: 'shade-light' (░), 'shade-medium' (▒), 'shade-heavy' (▓), 'dots' (·), 'cross' (╳), 'grid' (┼), 'dash' (╌). Or pass any single character: texture: '~'.

Texture characters show through spaces in text rendered on top (unless the text has an explicit bg, which claims the cell).

Absolute Positioning

Position relative to parent, removed from flex flow.

<box style={{ border: 'round', height: 5, flexDirection: 'column' }}>
  <text>content here</text>
  <box style={{ position: 'absolute', top: 0, right: 1 }}>
    <text style={{ color: 'green', bold: true }}>ONLINE</text>
  </box>
</box>

If both left and right are set, width is derived (same for top/bottom).

Box, Text, and Spacer are convenience wrappers:

import { Box, Text, Spacer } from '@trendr/core'
<Box style={{ flexDirection: 'row' }}><Text>hello</Text><Spacer /><Text>right</Text></Box>

Hooks

useInput

Used in counter, dashboard, explorer, chat, modal-form, components, focus-demo

useInput((event) => {
  // event.key: 'a', 'return', 'escape', 'up', 'down', 'left', 'right',
  //            'tab', 'shift-tab', 'space', 'backspace', 'delete',
  //            'home', 'end', 'pageup', 'pagedown', 'f1'-'f12'
  // event.ctrl: boolean
  // event.meta: boolean (alt/option key)
  // event.raw: raw character string
  // event.stopPropagation(): prevent other handlers from receiving this event
})

Handlers fire in reverse registration order (innermost component first). Call stopPropagation() to consume the event.

useHotkey

Declarative key binding. Parses 'ctrl+s', 'alt+enter', etc.

import { useHotkey } from '@trendr/core'

useHotkey('ctrl+s', () => save())
useHotkey('alt+enter', () => submit(), { when: () => isFocused })

useLayout

Returns a live reference to the component's computed layout rectangle. Values update in-place each frame, including after terminal resize.

const layout = useLayout()
// layout.x, layout.y, layout.width, layout.height, layout.contentHeight

useResize

useResize(({ width, height }) => { /* terminal resized */ })

useInterval

Used in dashboard

useInterval(() => tick(), 1000) // auto-cleaned on unmount

useTimeout

Used in timeout

Single-shot timer. Auto-cleaned on unmount.

useTimeout(() => hide(), 3000)

useAsync

Used in async

Async function to reactive signals.

import { useAsync } from '@trendr/core'

const { status, data, error, run } = useAsync(fetchUsers)

// status(): 'idle' | 'loading' | 'success' | 'error'
// data():   resolved value (null until success)
// error():  rejected error (null until error)
// run():    trigger the async function. forwards args: run(userId)

Stale calls are discarded. Use { immediate: true } to fire on mount:

const { status, data } = useAsync(fetchUsers, { immediate: true })

useMouse

useMouse((event) => {
  // event.action: 'press' | 'release' | 'drag' | 'scroll'
  // event.button: 'left' | 'middle' | 'right' (press/release only)
  // event.direction: 'up' | 'down' (scroll only)
  // event.x, event.y: 0-based terminal coordinates
  // event.stopPropagation(): prevent other handlers from receiving this event
})

Mouse is enabled automatically. Built-in components support click, scroll wheel, and scrollbar dragging.

useStdout

const stream = useStdout() // the output stream (process.stdout or custom)

useRepaint

Forces a full repaint. Useful after spawning an external process (e.g. $EDITOR).

import { useRepaint, useStdout, exitAltScreen, showCursor, altScreen, hideCursor } from '@trendr/core'

const repaint = useRepaint()
const stdout = useStdout()

stdout.write(exitAltScreen + showCursor)
execSync(`${process.env.EDITOR} ${file}`, { stdio: 'inherit' })
stdout.write(altScreen + hideCursor)
repaint()

useTheme

Returns the current theme object. See Theming.

const { accent } = useTheme()

useFocus

Used in explorer, chat, modal-form, components, focus-demo, layout

Register named items in tab order. The focus manager tracks which is active.

import { useFocus } from '@trendr/core'

const fm = useFocus({ initial: 'input' })

// declaration order = tab order
fm.item('input')     // tab stop 0
fm.item('list')      // tab stop 1
fm.item('sidebar')   // tab stop 2

Wire fm.is() to each component's focused prop. Tab/shift-tab cycles through items.

<TextInput focused={fm.is('input')} />
<List focused={fm.is('list')} />
<Select focused={fm.is('sidebar')} />

fm.focus('list')  // jump programmatically
fm.current()      // the active name

Groups nest multiple items under one tab stop:

fm.group('settings', { items: ['theme', 'autosave', 'format'] })
// fm.is('theme'), fm.is('autosave'), etc. work within the group

Options:

  • navigate - which keys move between group items: 'both' (default, j/k and up/down), 'jk', or 'updown'
  • wrap - wrap around at ends (default false)

Stack-based focus for modals - push saves current focus, pop restores it:

fm.push('modal')  // save current focus, switch to 'modal'
fm.pop()          // restore previous focus

useToast

Used in chat, modal-form, components

import { useToast } from '@trendr/core'

const toast = useToast({
  duration: 2000,           // ms, default 2000
  position: 'bottom-right', // see positions below
  margin: 1,                // padding from screen edge, default 1
  render: (message) => (    // optional custom render
    <box style={{ bg: '#1E1E1E', paddingX: 1 }}>
      <text style={{ color: '#9A9EA3' }}>{message}</text>
    </box>
  ),
})

toast('saved')

// positions: 'top-left', 'top-center', 'top-right',
//            'center-left', 'center', 'center-right',
//            'bottom-left', 'bottom-center', 'bottom-right'

Components

All interactive components accept a focused prop. Wire it to a focus manager so only one component captures keys at a time:

const fm = useFocus({ initial: 'search' })
fm.item('search')
fm.item('results')

<TextInput focused={fm.is('search')} />
<List focused={fm.is('results')} />

TextInput

Used in explorer, modal-form, focus-demo

Single-line text input with horizontal scrolling.

<TextInput
  focused={fm.is('search')}
  placeholder="search..."
  onChange={v => {}}   // every keystroke
  onSubmit={v => {}}   // Enter
  onCancel={() => {}}  // Escape (only stopPropagates if provided)
/>

Keys: left/right, home/end, ctrl-a/e, ctrl-u/k/w, backspace, delete.

TextArea

Used in chat

Multi-line text input. Auto-grows up to maxHeight, then scrolls.

<TextArea
  focused={fm.is('input')}
  placeholder="write something..."
  maxHeight={10}         // default 10
  onChange={v => {}}     // every edit
  onSubmit={v => {}}     // Alt+Enter
  onCancel={() => {}}    // Escape
/>

Keys: Enter inserts newline. Up/down with sticky goal column. Home/end operate on display rows. Ctrl-u/k/w operate on logical lines.

List

Used in explorer, chat, modal-form, components, focus-demo, layout

Scrollable list with keyboard navigation.

<List
  items={data}
  selected={selectedIndex}  // controlled, or omit for internal state
  onSelect={setIndex}
  focused={fm.is('list')}
  scrollbar={true}          // default false
  scrolloff={2}             // items of margin from edges when scrolling (default 2)
  interactive={true}        // handle keyboard input (default: same as focused)
  header={<text>title</text>}
  headerHeight={1}          // default 1, rows the header occupies
  renderItem={(item, { selected, index, focused }) => (
    <text style={{ bg: selected ? (focused ? accent : 'gray') : null }}>{item.name}</text>
  )}
/>

itemHeight enables multi-row items (tells scroll math how many rows each item occupies):

<List
  items={data}
  itemHeight={3}
  renderItem={(item, { selected, focused }) => (
    <box style={{ flexDirection: 'column', bg: selected ? accent : null }}>
      <text style={{ bold: true }}>{item.name}</text>
      <text style={{ color: 'gray' }}>{item.description}</text>
      <text style={{ color: 'green' }}>{item.status}</text>
    </box>
  )}
/>

Keys: j/k or up/down, g/G for top/bottom, ctrl-d/u half page, ctrl-f/b full page, pageup/pagedown.

PickList

Used in pick-list

Filterable list with live search. Text input at the top filters a scrollable list below. Navigate the list with up/down or ctrl-n/ctrl-p while typing.

<PickList
  items={data}
  focused={fm.is('search')}
  placeholder="search..."
  onSelect={item => {}}       // Enter on highlighted item
  onCancel={() => {}}          // Escape
  onChange={query => {}}       // every keystroke in the filter
  clearOnSelect={false}        // reset filter on select (default false)
  scrollbar={true}             // default false
  scrolloff={2}                // items of margin from edges (default 2, inherited from List)
  gap={1}                      // space between input and list (default 0)
  filter={(query, item) => {}} // custom filter (default: case-insensitive includes)
/>

Multi-row items with renderItem, itemHeight, and itemGap:

<PickList
  items={packages}
  itemHeight={3}
  itemGap={1}
  renderItem={(pkg, { selected, focused }) => (
    <box style={{ flexDirection: 'column', bg: selected ? accent : null, paddingX: 1 }}>
      <text style={{ bold: true }}>{pkg.name}</text>
      <text style={{ color: 'gray' }}>{pkg.desc}</text>
      <text style={{ color: 'yellow' }}>{pkg.downloads}</text>
    </box>
  )}
/>

Keys: type to filter, up/down or ctrl-n/ctrl-p to navigate, enter to select, escape to cancel. All bash-style editing keys work (ctrl-a/e/u/k/w).

Table

Used in components, custom-table

Column-based data table. Uses List internally.

<Table
  columns={[
    { header: 'Name', key: 'name', flexGrow: 1 },
    { header: 'Size', key: 'size', width: 10, color: 'gray', paddingX: 1 },
    { header: 'Type', render: (row, sel) => row.type.toUpperCase(), width: 8 },
  ]}
  data={rows}
  selected={selectedRow}
  onSelect={setRow}
  focused={fm.is('table')}
  separator={true}              // horizontal rule below header
  separatorChars={{ left: '', fill: '─', right: '' }}  // customizable
/>

renderItem gives full control over row rendering while keeping column-aligned headers:

<Table
  columns={columns}
  data={rows}
  selected={idx()}
  onSelect={setIdx}
  renderItem={(row, { selected, focused }) => (
    <box style={{ flexDirection: 'row', bg: selected ? accent : null, paddingX: 1 }}>
      <text style={{ color: selected ? 'black' : null, flexGrow: 1 }}>{row.name}</text>
      <text style={{ color: selected ? 'black' : row.stale ? 'yellow' : 'gray' }}>{row.age}</text>
    </box>
  )}
/>

Tabs

Used in chat

<Tabs
  items={['general', 'settings', 'logs']}
  selected={activeTab}
  onSelect={setTab}
  focused={fm.is('tabs')}
/>

Keys: left/right, tab/shift-tab. Wraps around.

Select

Used in modal-form, components, focus-demo

Dropdown selector. Can render inline or as overlay.

<Select
  items={['red', 'green', 'blue']}
  selected={color}
  onSelect={setColor}
  focused={fm.is('color')}
  overlay={false}          // true renders as floating overlay
  placeholder="pick one..."
  openIcon="▲"             // default ▲
  closedIcon="▼"           // default ▼
  renderItem={(item, { selected, index }) => <text>{item}</text>}
  style={{
    border: 'single', borderColor: 'green', bg: 'black',
    cursorBg: 'green', cursorTextColor: 'black',
    color: null, focusedColor: 'green',
  }}
/>

Keys: j/k or up/down to navigate, enter/space to select, escape to close.

Checkbox

Used in modal-form, components, focus-demo

<Checkbox
  checked={isChecked}
  label="Enable feature"
  onChange={setChecked}  // (newState: boolean) => void
  focused={fm.is('feature')}
  checkedIcon="[✓]"     // default '[x]'
  uncheckedIcon="[ ]"   // default '[ ]'
/>

Keys: space or enter to toggle.

Radio

Used in modal-form, components, focus-demo

<Radio
  options={['small', 'medium', 'large']}
  selected={size}
  onSelect={setSize}
  focused={fm.is('size')}
/>

Keys: j/k or up/down, enter/space to select. Renders / .

ProgressBar

Used in progress, components

<ProgressBar
  value={0.65}              // 0 to 1
  variant="thin"            // 'thin' (default), 'block', 'ascii', 'braille'
  color="red"               // overrides theme accent
  label="Installing"        // optional label before bar
  count="8/12"              // optional count after percentage
  percentage={true}         // show percentage (default true)
  width={30}                // override bar width (default: fills available space)
/>

Variants:

  • thin - clean bar (default)
  • block - thick █░ blocks
  • ascii - plain [###---], works in any terminal
  • braille - smooth fill
Installing  ━━━━━━━━━━━━━━━━━━━━━━━━ 67% (8/12)

Spinner

Used in components

<Spinner
  label="loading..."
  variant="dots"     // 'dots' (default), 'line', 'circle', 'bounce', 'arrow', 'square', 'star'
  color="magenta"    // overrides theme accent
  interval={80}      // ms, default 80
  frames={['a','b']} // custom frames (overrides variant)
/>

Task

Used in task

Spinner while loading, checkmark on success, x on error. Built on useAsync.

<Task
  run={() => fetchData()}   // async function
  label="Fetching data..."  // shown while loading
  successLabel="Done"       // optional, shown on success (defaults to label)
  errorLabel="Failed"       // optional, shown on error (defaults to error message)
  immediate={true}          // fire on mount (default true)
  icon={{ success: '+' }}   // override icons per status
  color="cyan"              // override color (defaults vary by status)
/>

Multiple tasks render as a step list:

<Task run={() => install()} label="Installing..." successLabel="Installed" />
<Task run={() => build()} label="Building..." successLabel="Built" />
<Task run={() => test()} label="Testing..." successLabel="Tests passed" />

Shimmer

Used in shimmer

Sliding highlight effect with gradient falloff.

<Shimmer
  color="gray"         // base text color (default 'gray')
  highlight="cyan"     // shimmer color (default: theme accent)
  size={3}             // width of bright center in chars (default 3)
  gradient={3}         // gradient tail length each side (default 3, 0 for hard edge)
  duration={1000}      // ms for one pass across the text (default 1000)
  delay={500}          // ms pause between passes (default 500)
  reverse={false}      // slide right to left (default false)
>
  Loading resources...
</Shimmer>

Button

Used in modal-form

Focusable button. Enter or space to activate.

<Button
  label="save"
  onPress={() => save()}
  focused={fm.is('save')}
  variant="dim"   // optional, grays out when unfocused
/>

Modal

Used in modal-form, components, focus-demo

Centered overlay with dimmed backdrop. Height is driven by content.

<Modal
  open={isOpen}
  onClose={() => setOpen(false)}
  title="Confirm"
  width={40}     // default 40
>
  <text>Are you sure?</text>
  <Button label="ok" onPress={() => setOpen(false)} focused={fm.is('ok')} />
</Modal>

Keys: escape to close.

ScrollableText

Used in explorer, reader, highlight

Scrollable text viewer. ANSI escape sequences are parsed and rendered, so syntax highlighter output (shiki, cli-highlight, etc.) works directly.

<ScrollableText
  content={longText}
  focused={fm.is('preview')}
  scrollOffset={offset}    // controlled, or omit for internal state
  onScroll={setOffset}
  scrollbar={true}         // default false
  wrap={false}             // default true, false truncates long lines
  thumbChar="█"            // default █
  trackChar="│"            // default │
/>

Keys: same as List (j/k, g/G, ctrl-d/u, ctrl-f/b, pageup/pagedown).

ScrollBox

Scrollable container for JSX children (vs ScrollableText which takes a string).

<ScrollBox
  focused={fm.is('list')}
  scrollbar={true}          // default false
  scrollOffset={offset}     // controlled, or omit for internal state
  onScroll={setOffset}
  thumbChar="█"             // default █
  trackChar="│"             // default │
  style={{ flexGrow: 1 }}   // pass-through style for the scroll container
>
  {items.map(item => (
    <text key={item.id}>{item.name}</text>
  ))}
</ScrollBox>

Keys: same as List and ScrollableText.

SplitPane

Paneled layout with shared borders and junction characters. Sizes use fr units or fixed values.

import { SplitPane } from '@trendr/core'

<SplitPane direction="row" sizes={[20, '2fr', '1fr']} border="round" borderColor="gray">
  <box style={{ paddingX: 1 }}>
    <text>sidebar</text>
  </box>
  <box style={{ paddingX: 1 }}>
    <text>main content</text>
  </box>
  <box style={{ paddingX: 1 }}>
    <text>detail</text>
  </box>
</SplitPane>

Props:

  • direction - 'row' (vertical dividers) or 'column' (horizontal dividers)
  • sizes - array of fixed numbers or 'Nfr' strings. [20, '1fr'] = 20 cols fixed + rest. ['1fr', '1fr'] = even split. Defaults to equal fractions.
  • border - 'single' | 'double' | 'round' | 'bold'
  • borderColor - color for border and dividers
  • borderEdges - object with top, right, bottom, left booleans to render only specific sides. Omitted keys default to false.

Nesting works:

<SplitPane direction="column" sizes={['1fr', 8]} border="round">
  <SplitPane direction="row" sizes={[20, '1fr']} border="round">
    <box>nav</box>
    <box>main</box>
  </SplitPane>
  <box>status</box>
</SplitPane>

Animation

Physics-based animation. Animated values are signals that trigger re-renders.

import { useAnimated, spring, ease, decay } from '@trendr/core'

const x = useAnimated(0, spring())    // spring physics
x.set(100)                            // animate to 100
x()                                   // read current value (tracks as signal)
x.snap(50)                            // jump instantly, no animation

useAnimated is the hook version (auto-cleanup on unmount). animated is the standalone version for use outside components.

Interpolators

spring({ frequency: 2, damping: 0.3 })   // underdamped spring (bouncy)
spring({ damping: 1 })                    // critically damped (no bounce)
ease(300)                                 // 300ms ease-out-cubic
ease(500, linear)                         // 500ms linear
decay({ deceleration: 0.998 })            // momentum-based decay

Switch interpolator mid-animation:

x.setInterpolator(ease(200))
x.set(newTarget)

Easing functions

linear, easeInQuad, easeOutQuad, easeInOutQuad, easeInCubic, easeOutCubic, easeInOutCubic, easeOutElastic(), easeOutBounce()

Tick callback

x.onTick((value) => { /* called each frame while animating */ })

Build

Uses esbuild. JSX configured with jsxImportSource: 'trend'.

node esbuild.config.js

Examples run via npm scripts:

npm run counter
npm run chat
npm run dashboard
npm run explorer
npm run highlight