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

snice

v4.2.0

Published

Imperative TypeScript framework for building vanilla web components with decorators, differential rendering, routing, and controllers. No virtual DOM, no build complexity.

Readme

Snice

AI Assistants: For token-efficient documentation, read docs/ai/ instead. Same content, 70% fewer tokens.

A TypeScript framework for building sustainable web applications through clear separation of governance.

Quick Start

npx snice create-app my-app
cd my-app
npm run dev

Philosophy

Without structure, you'll end up with auth checks mixed in rendering logic, business rules duplicated across components, and data fetching scattered in event handlers. Snice provides constructs that guide you to put code where it belongs, keeping you productive as your app grows.

  • Pages fetch data and assemble UI - They understand what the user wants to do
  • Elements handle visuals only - They don't know or care about business logic
  • Controllers let you swap behavior - Same UI, different data sources or logic
  • Cross-cutting concerns stay separate - Auth, routing, and global state don't leak into your components

Yes, global state is bad! but you will have a little always, and it should be managed well. Usually we see auth/principals, themes, and localization as global state examples.

Each piece hints at where your code should live, preventing the mess that kills velocity on larger teams.

The Tools

Snice provides decorators and utilities that map directly to these architectural concerns:

Basic Building Blocks


@page({ tag: 'user-profile-page', routes: ['/users/:userId'], guards: [isAuthenticated] })
class UserProfilePage extends HTMLElement { ... }

@element('user-stats')
class UserStats extends HTMLElement { ... }

@controller('real-time-user-loader')
class RealTimeUserLoader { ... }

// And within these classes, use decorators like:
@property() name = 'default';
@render() fn() { return html`...`; }
@styles() fn() { return css`...`; }
@ready() async fn() { ... }
@dispose() fn() { ... }
@watch('name') fn(oldVal, newVal) { ... }
@query('input') input!: HTMLInputElement;
@queryAll('.item') items!: NodeListOf<HTMLElement>;
@on('click', 'button') fn(e: Event) { ... }
@dispatch('value-changed') fn(val: string) => Event Detail
@context() fn(ctx: Context) { ... }
@request('user') fn(): () => Request;
@respond('user') fn(req) => Response;

1. Cross-Cutting Concerns: Router + Context

// sample-app-context.ts
class AppContext {
  user: User | null = null;
  theme: 'light' | 'dark' = 'light';

  setUser(user: User) { this.user = user; }
  getUser() { return this.user; }
}

// main.ts
import { Router } from 'snice';

const { page, navigate, initialize } = Router({
  target: '#app',
  context: new AppContext(),  // Global state
  type: 'hash'
});

// Any page can access context
@page({ tag: 'dashboard-page', routes: ['/dashboard'] })
class DashboardPage extends HTMLElement {
  private appContext?: AppContext;

  @context()
  handleContext(ctx: Context) {
    this.appContext = ctx.application;
    const user = this.getUser();
  }
  // ...
}

2. Pages: Orchestrating Intent

// pages/user-profile-page.ts
@page({ tag: 'user-profile-page', routes: ['/users/:userId'] })
class UserProfilePage extends HTMLElement {
  @property()
  userId = '';  // From URL parameter

  @property({ type: Object })
  user = null;

  @property({ type: Object })
  userStats = null;

  @ready()
  async loadUserData() {
    // Pages handle data fetching, elements just display
    const [user, stats] = await Promise.all([
      fetch(`/api/users/${this.userId}`).then(r => r.json()),
      fetch(`/api/users/${this.userId}/stats`).then(r => r.json())
    ]);
    this.user = user;
    this.userStats = stats;
  }

  @render()
  renderContent() {
    return html`
      <page-header .user=${this.user}></page-header>
      <user-stats .stats=${this.userStats}></user-stats>
      <user-activity .userId=${this.userId}></user-activity>
    `;
  }
}

3. Elements: Pure Presentation

// elements/user-stats.ts
@element('user-stats')
class UserStats extends HTMLElement {
  @property({ type: Object })
  stats = null;

  @render()
  renderContent() {
    if (!this.stats) return html`<div>Loading...</div>`;

    return html`
      <div class="stats">
        <div class="stat">
          <span class="label">Views</span>
          <span class="value">${this.stats.views}</span>
        </div>
        <div class="stat">
          <span class="label">Followers</span>
          <span class="value">${this.stats.followers}</span>
        </div>
      </div>
    `;
  }

  @styles()
  statsStyles() {
    return css`
      .stats { display: flex; gap: 2rem; }
      .stat { text-align: center; }
    `;
  }
}

// Usage in parent page (which handles data fetching):
// <user-stats .stats=${this.userStats}></user-stats>

4. Controllers: Behavior Management

// controllers/real-time-user-loader.ts
@controller('real-time-user-loader')
class RealTimeUserLoader {
  async attach(element: IUserList) {
    this.socket = new WebSocket('/api/users/stream');
    this.socket.onmessage = (e) => {
      element.setUsers(JSON.parse(e.data));
    };
  }
  // ...
}

// controllers/cached-user-loader.ts
@controller('cached-user-loader')
class CachedUserLoader {
  async attach(element: IUserList) {
    const cached = localStorage.getItem('users');
    if (cached) element.setUsers(JSON.parse(cached));
  }
  // ...
}

// elements/user-list.ts - stays the same
@element('user-list')
class UserList extends HTMLElement {
  setUsers(users: User[]) {
    this.users = users;
    // ...
  }

  @render()
  renderContent() {
    return html`
      <ul>${this.users.map(u => html`<li>${u.name}</li>`)}</ul>
    `;
  }
}

Usage - swap behavior without touching presentation:

<user-list controller="real-time-user-loader"></user-list>
<user-list controller="cached-user-loader"></user-list>

Key Features

Differential Rendering - Only updates changed parts of the DOM, not entire components

Auto-Rendering - Components automatically re-render when properties change

Template Syntax - Clean html\...`andcss`...`` tagged templates

Type Safety - Full TypeScript support with decorator-based APIs

Zero Dependencies - No external runtime dependencies

Standards-Based - Built on web components, works with any framework

Core APIs

Class Decorators

@element('tag-name') - Define reusable UI components

@element('my-button')
class MyButton extends HTMLElement { }

@page({ tag, routes }) - Define routable pages

@page({ tag: 'home-page', routes: ['/'] })
class HomePage extends HTMLElement { }

@controller('controller-name') - Define behavior modules

@controller('data-loader')
class DataLoader {
  async attach(element) { }
  async detach(element) { }
}

Rendering

@render(options?) - Define component template (auto re-renders on property changes)

@render()
renderContent() {
  return html`<div>${this.data}</div>`;
}

@styles() - Define scoped styles

@styles()
componentStyles() {
  return css`.container { padding: 1rem; }`;
}

Properties & State

@property(options?) - Reactive properties that sync with attributes

@property()
name = 'default';

@property({ type: Boolean })
enabled = false;

@watch(...propertyNames) - React to property changes

@watch('name')
onNameChange(oldVal, newVal) {
  console.log(`Name changed from ${oldVal} to ${newVal}`);
}

Lifecycle

@ready() - Runs after initial render completes

@ready()
async initialize() {
  // Fetch data, set up listeners, etc.
}

@dispose() - Runs when element is removed from DOM

@dispose()
cleanup() {
  // Clean up listeners, close connections, etc.
}

DOM Queries

@query(selector) - Query single element from shadow DOM

@query('input')
input!: HTMLInputElement;

@queryAll(selector) - Query multiple elements from shadow DOM

@queryAll('.item')
items!: NodeListOf<HTMLElement>;

Events & Communication

Template Events - Handle events directly in templates (with keyboard modifiers!)

html`
  <button @click=${this.handleClick}>Click</button>
  <input @keydown:Enter=${this.submit} />
  <input @keydown:ctrl+s=${this.save} />
`

@on Decorator - Event delegation with selectors

// Works in both elements AND controllers
@on('click', 'button')  // Event delegation
handleClick(e: Event) {
  console.log('Button clicked!');
}

@on('keydown:Enter', 'input')  // Keyboard modifiers
handleEnter(e: KeyboardEvent) {
  this.submit();
}

@on('input', 'input', { debounce: 300 })  // Debounce support
handleInput(e: Event) {
  this.search((e.target as HTMLInputElement).value);
}

@dispatch(eventName) - Auto-dispatch custom events after method execution

@dispatch('value-changed')
setValue(val: string) {
  this.value = val;
  return { value: val };  // Event detail
}

Global State

@context(options?) - Receive router context updates (global state)

// Method decorator that receives context updates
@context()
handleContext(ctx: Context) {
  this.appContext = ctx.application;
  this.requestRender();
}

// With timing options
@context({ debounce: 300 })
handleContextDebounced(ctx: Context) {
  // Called after 300ms of no updates
}

@context({ throttle: 100 })
handleContextThrottled(ctx: Context) {
  // Called at most once per 100ms
}

@context({ once: true })
handleContextOnce(ctx: Context) {
  // Called only once, then unregisters
}

Context Object Structure:

interface Context {
  application: AppContext;  // Your router context
  navigation: {
    placards: Placard[];    // Page metadata
    route: string;          // Current route
    params: Record<string, string>;  // Route parameters
  };
  update(): void;  // Notify all subscribers
}

Triggering Context Updates:

When you modify the application context, call update() to notify all subscribers:

@page({ tag: 'login-page', routes: ['/login'] })
class LoginPage extends HTMLElement {
  private ctx?: Context;

  @context()
  handleContext(ctx: Context) {
    this.ctx = ctx;
    this.requestRender();
  }

  login(user: User) {
    // Modify the application context
    this.ctx!.application.setUser(user);

    // Notify all @context subscribers
    this.ctx!.update();
  }
}

Note: The router calls update() automatically during navigation. Only call it manually when you change application state (like login/logout, theme changes, etc.).

Request/Response

For the few cases where elements need to request data from controllers (like fetching user info or current state), Snice provides a request/response pattern:

@request(channel) - Make requests to controllers from elements @respond(channel) - Respond to requests from elements in controllers

This pattern is useful when:

  • Elements need to fetch data without direct controller access
  • You want to keep elements decoupled from specific controller implementations
  • Multiple elements may request the same data

Example:

// Controller responds to requests
@element('app-controller')
class AppController extends HTMLElement {
  private currentUser = { name: 'Alice', role: 'admin' };

  @respond('user')
  getUserData() {
    return this.currentUser;
  }
}

// Element makes requests
@element('user-badge')
class UserBadge extends HTMLElement {
  @request('user')
  getUser!: () => any;

  @ready()
  init() {
    const user = this.getUser();
    console.log('Current user:', user);
  }

  @render()
  renderContent() {
    const user = this.getUser();
    return html`<div>Welcome, ${user.name}!</div>`;
  }
}

Usage:

<app-controller>
  <user-badge></user-badge>
</app-controller>

See Request/Response documentation for details.

Template Syntax

Auto-Rendering with Differential Updates

@element('counter-display')
class CounterDisplay extends HTMLElement {
  @property({ type: Number })
  count = 0;

  @render()
  renderContent() {
    return html`
      <div class="counter">
        <span class="count">${this.count}</span>
        <button @click=${this.increment}>+</button>
      </div>
    `;
  }

  @styles()
  counterStyles() {
    return css`.counter { display: flex; gap: 1rem; }`;
  }

  increment() {
    this.count++;
    // Auto re-renders! Only <span class="count"> updates
  }
}

Key Points:

  • Properties trigger automatic re-renders
  • Only changed parts update (differential rendering)
  • Event handlers: @click=${this.method}
  • Batched updates (multiple changes = single render)

Property Binding

Use .property=${value} to set element properties directly:

html`
  <input .value=${this.text} />
  <custom-element .complexData=${this.dataObject}></custom-element>
`

Boolean Attributes

Use ?attribute=${boolean} for boolean attributes:

html`
  <button ?disabled=${this.isLoading}>Submit</button>
  <input type="checkbox" ?checked=${this.isChecked} />
`

Conditionals

// Ternary operator
html`
  ${this.isLoggedIn
    ? html`<span>Welcome!</span>`
    : html`<a href="/login">Login</a>`
  }
`

// <if> conditional element
html`
  <if ${this.isLoggedIn}>
    <span>Welcome, ${this.user.name}!</span>
    <button @click=${this.logout}>Logout</button>
  </if>
  <if ${!this.isLoggedIn}>
    <a href="/login">Login</a>
  </if>
`

// <case>/<when>/<default> for multiple branches
html`
  <case ${this.status}>
    <when value="loading">
      <span>Loading...</span>
    </when>
    <when value="success">
      <span>Success!</span>
    </when>
    <when value="error">
      <span>Error occurred</span>
    </when>
    <default>
      <span>Unknown status</span>
    </default>
  </case>
`

Lists

html`
  <ul>
    ${this.items.map(item => html`
      <li @click=${() => this.select(item.id)}>${item.name}</li>
    `)}
  </ul>
`

Keyboard Shortcuts

html`
  <input @keydown.enter=${this.submit} />
  <input @keydown.ctrl+s=${this.save} />
  <input @keydown.ctrl+shift+s=${this.saveAs} />
  <input @keydown.escape=${this.cancel} />
  <input @keydown.~enter=${this.submitAny} />
`

Keyboard syntax:

  • @keydown.enter - Plain Enter (no modifiers)
  • @keydown.ctrl+s - Ctrl+S combination
  • @keydown.~enter - Enter with any modifiers
  • @keydown.down - Arrow keys (up, down, left, right)
  • @keydown.escape - Escape key

Router

// main.ts
const { page, navigate, initialize } = Router({
  target: '#app',
  context: new AppContext()
});

// pages/home-page.ts
@page({ tag: 'home-page', routes: ['/'] })
class HomePage extends HTMLElement {
  @render()
  renderContent() {
    return html`<h1>Home</h1>`;
  }
}

// pages/user-page.ts
@page({ tag: 'user-page', routes: ['/users/:userId'] })
class UserPage extends HTMLElement {
  @property()
  userId = '';  // Auto-populated from URL
  // ...
}

// main.ts
initialize();
navigate('/users/123');

Route Guards

Protect routes with guard functions:

const isAuthenticated: Guard<AppContext> = (ctx) => ctx.getUser() !== null;

@page({
  tag: 'dashboard-page',
  routes: ['/dashboard'],
  guards: isAuthenticated
})
class DashboardPage extends HTMLElement { }

Layouts

Layouts wrap pages with shared UI and dynamically build navigation from page metadata:

// layouts/app-shell.ts
@layout('app-shell')
class AppShell extends HTMLElement implements Layout {
  private placards: Placard[] = [];
  private currentRoute = '';

  @render()
  renderContent() {
    return html`
      <header>
        <nav>
          ${this.placards
            .filter(p => p.show !== false)
            .map(p => html`
              <a href="#/${p.name}"
                 class="${this.currentRoute === p.name ? 'active' : ''}">
                ${p.icon} ${p.title}
              </a>
            `)}
        </nav>
      </header>
      <main><slot name="page"></slot></main>
    `;
  }

  // Called when route changes
  update(appContext, placards, currentRoute, routeParams) {
    this.placards = placards;
    this.currentRoute = currentRoute;
    // Property changes trigger re-render
  }
}

// main.ts - configure router with layout
const { page, initialize } = Router({
  target: '#app',
  layout: 'app-shell'
});

Pages render inside <slot name="page"></slot>. Layout persists, only page content swaps.

Placards

Page metadata that layouts use to build navigation, breadcrumbs, and help systems:

// pages/dashboard-page.ts
const placard: Placard<AppContext> = {
  name: 'dashboard',
  title: 'Dashboard',
  icon: '📊',
  order: 1,
  searchTerms: ['home', 'overview', 'stats'],
  hotkeys: ['ctrl+d'],
  visibleOn: [isAuthenticated]
};

@page({
  tag: 'dashboard-page',
  routes: ['/dashboard'],
  placard: placard
})
class DashboardPage extends HTMLElement { }

Features:

  • Navigation - title, icon, order, show
  • Hierarchy - parent, group, breadcrumbs
  • Discovery - searchTerms, hotkeys, tooltip
  • Visibility - visibleOn guards control who sees what

Layouts receive placard data in update() and auto-build navigation. See docs.

Migrating from v2.x

v3.0.0 introduces template-based rendering with differential updates. Key changes:

  • Use @render() instead of html() method Return html\...`` tagged template instead of string

  • Use @styles() instead of css() method Return css\...`` tagged template instead of string

  • @on() decorator available Works in both elements AND controllers with full event delegation, keyboard modifiers, and debounce/throttle support. Template event syntax (@click=${handler}) is also available as an alternative.

  • @part decorator removed Differential rendering makes selective re-rendering unnecessary

See Migration Guide for detailed migration guide.

Documentation

  • Elements API - Complete guide to creating elements with properties, queries, and styling
  • Controllers API - Data fetching, business logic, and controller patterns
  • Routing API - Single-page application routing with transitions
  • Placards API - Rich page metadata for dynamic navigation and discovery
  • Events API - Event handling, dispatching, and custom events
  • Request/Response API - Bidirectional communication between elements and controllers
  • Observe API - Lifecycle-managed observers for external changes

License

MIT