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

@reblu/site-ui

v0.7.1

Published

Public presentational primitives for Reblu SITEs (Button, Link, Input, Label, Form, Tabs, Pagination, cn).

Readme

@reblu/site-ui

Public presentational primitives for Reblu SITEs. The distributable, tsup-built counterpart of the private @reblu/ui — @reblu/ui re-exports these so the internal and public components can never diverge (REB-306, design D5/D6).

Entry points

| Import | Exports | Peer deps | | --- | --- | --- | | @reblu/site-ui | Button, buttonVariants, Input, Toast, buildPageHref, cn | react >=19 | | @reblu/site-ui/utils | cn | — | | @reblu/site-ui/button-variants | buttonVariants | — | | @reblu/site-ui/pagination-href | buildPageHref | — | | @reblu/site-ui/next | Link | react >=19, next >=16 | | @reblu/site-ui/pagination | Pagination | react >=19, next >=16 | | @reblu/site-ui/form | Form, Label, … | react >=19, react-hook-form >=7.57 | | @reblu/site-ui/tabs | Tabs, … | react >=19 | | @reblu/site-ui/label | Label | react >=19 | | @reblu/site-ui/phone | normalizePhoneToE164, isSupportedRegion, getPhoneDialCode, … | — | | @reblu/site-ui/phone-input | PhoneInput | react >=19 |

The root entry is framework-agnostic and safe inside React Server Components. The Link wrapper carries a 'use client' boundary and wraps next/link, so it lives under the ./next subpath where next is a required peer — and Pagination lives under ./pagination for the same reason. next is declared as an OPTIONAL peer, so a consumer that never touches those two subpaths is not asked to install it.

The three dependency-free subpaths (./utils, ./button-variants, ./pagination-href) exist so a Server Component can reach a class merger, the Button's class list, or the pagination URL rule without entering a React or Radix module graph (REB-647). All three are re-exported from the root as well, so the subpaths are additive, never moves.

import { Button, cn } from '@reblu/site-ui'
import Link from '@reblu/site-ui/next'
import { Pagination } from '@reblu/site-ui/pagination'

Phone input

PhoneInput is a controlled, form-library-independent field, exported from ./phone-input rather than the root so importing Button does not load the phone metadata. Supply the tenant's known two-letter region to resolve national numbers and show its calling prefix. It never guesses a region from the browser. Valid international numbers are honored regardless of this region. Incomplete numbers remain editable; a valid number is emitted as E.164. An empty optional field emits ''. Pass error for an associated accessible field message. The visible controlled value changes to E.164 as soon as valid; a SITE needing different editing ergonomics can use the pure ./phone helper at its own submit boundary instead.

import { PhoneInput } from '@reblu/site-ui/phone-input'

<PhoneInput id='customer-phone' label='Teléfono' region='ES'
  value={phone} onChange={setPhone} error={phoneError} />

normalizePhoneToE164 is now defined in this distributable package; the private @reblu/ui/phone path re-exports the same implementation for existing callers. An unresolved value is not safe to serialize as E.164: keep server validation.

Toast

A minimal, themeable toast CONTENT shell (REB-837) — deliberately NOT a toast manager: no portal, no positioning, no auto-dismiss timer, no aria-live region. site-ui does not take a sonner (or any toaster) dependency for this on purpose — a toaster is a runtime concern with its own positioning/ stacking/dismissal opinions, and pulling one into a primitives package would force it on every consumer. Render Toast AS the content of whichever toaster the consuming SITE already mounts:

import { Toast } from '@reblu/site-ui'
import { toast } from 'sonner'

toast.custom(() => (
  <Toast
    title='Serum'
    description='Añadido al carrito'
    variant='success'
    action={<a href='/shop/cart'>Ver carrito</a>}
  />
))

That host toaster owns the live region — do NOT wrap Toast in a second aria-live region, or the announcement doubles. @reblu/site-blocks's AddedToCartToast is the cart-specific, ready-made version of this pattern (product name, thumbnail, "Ver carrito" link, status copy) — reach for Toast directly only for a non-cart toast.

| Prop | Default | Notes | | --- | --- | --- | | title | — | required | | description | — | optional second line | | icon | — | leading slot, e.g. a thumbnail | | action | — | trailing slot, e.g. a link | | variant | 'default' | 'default' \| 'success' \| 'error', exposed via data-variant for theming |

Pagination

import { Pagination } from '@reblu/site-ui/pagination'

<Pagination
  currentPage={page}
  totalPages={result.meta?.pages ?? 1}
  basePath='/blog'
  searchParams={{ category, tag }}
/>

Renders nothing when totalPages <= 1. totalPages should come from the list envelope's meta.pages, never from data.length — a full LAST page is indistinguishable from "there is more", so deriving it offers a next page that renders the empty state.

| Prop | Default | Notes | | --- | --- | --- | | currentPage | — | 1-based; clamped into [1, totalPages] before rendering | | totalPages | — | from meta.pages | | basePath | — | e.g. /blog | | searchParams | {} | preserved in every link; an inherited page is ignored | | siblingCount | 1 | numbered links kept on each side of the current page | | variant | 'numbers' | 'compact' = prev/next around a status text | | labels | Spanish (below) | every string and aria-label is overridable | | className | — | merged onto the <nav> |

Numbered links are the default form. With prev/next only, a keyboard user cannot reach page 4 without three full server navigations, each one resetting focus, and nothing on screen says how far the end is. variant='compact' exists for rows too narrow for numbers; it is a fallback, not the shape to reach for.

aria-current='page' goes on the current numbered link and on nothing else. The attribute marks which member of a set of navigation links is current. On a standalone status <span> reading "Página 1 de 5" there is no set, so it informs nobody — and axe does not flag it, which is what makes that mistake durable. variant='compact' renders no aria-current at all.

Two more details worth knowing, because they are easy to "simplify" away:

  • At the first/last page the prev/next control is disabled in place (<span aria-disabled='true'>), not removed. A control that unmounts at the boundary re-centres the row, so the target slides out from under the pointer on the very click that reaches the last page.
  • The arrow glyphs are aria-hidden; the accessible name comes from aria-label. A screen reader announces ← as anything from "leftwards arrow" to silence.

rel='prev' / rel='next' are set on the prev/next links.

Labels — the defaults are Spanish

| key | default | | --- | --- | | nav | Paginación (the <nav> aria-label) | | previous / next | Anterior / Siguiente (visible text) | | previousAriaLabel / nextAriaLabel | Página anterior / Página siguiente | | previousGlyph / nextGlyph | ← / → (always aria-hidden) | | page | (n) => 'Página ' + n (a numbered link's accessible name) | | status | (n, m) => 'Página ' + n + ' de ' + m — variant='compact' only |

Nothing in the render path is unreachable from labels, and a partial object merges over the defaults:

<Pagination
  currentPage={page}
  totalPages={pages}
  basePath='/blog'
  labels={{
    nav: 'Pagination',
    previous: 'Previous',
    next: 'Next',
    previousAriaLabel: 'Previous page',
    nextAriaLabel: 'Next page',
    page: (n) => `Page ${n}`,
  }}
/>

buildPageHref — and why filter links need it

import { buildPageHref } from '@reblu/site-ui/pagination-href'
// also: import { buildPageHref } from '@reblu/site-ui'

buildPageHref('/blog', 2, { category: 'ia' })           // '/blog?category=ia&page=2'
buildPageHref('/blog', 1, { category: 'ia' })           // '/blog?category=ia'  (no ?page=1)
buildPageHref('/blog', 1, { category: 'ia', page: '7' })// '/blog?category=ia'  (page discarded)

Pagination uses this internally; it is exported because filter links need it too. A filter link built from a spread of the current request's params carries page along with it, and a different filter is a different result set with a different length — so preserving the offset drops the reader on a page that no longer exists. Page reset therefore lives in the URL construction, not in each caller's memory: page is deleted unconditionally and only re-set when it is > 1. A filter link is just buildPageHref(basePath, 1, nextParams).

Page 1 also drops the param entirely rather than emitting ?page=1, so the first page has exactly one URL — two URLs for one page split ISR/CDN cache entries and hand search engines a duplicate.

This module imports nothing (no React, no next), so a Server Component can call it. Do not import it from @reblu/site-ui/pagination: that entry is 'use client', and a function imported across that boundary comes back as a client reference.

Page-size params differ per endpoint — pageSize vs limit

When you wire Pagination to @reblu/site-client, note that the request param is not the same word on every route:

| SDK call | request param | response key | | --- | --- | --- | | client.blog.list({ limit }) | limit | meta.limit | | client.academy.courses({ limit }) | limit | meta.limit | | client.shop.products({ limit }) | limit | meta.limit | | client.pages.list({ pageSize }) | pageSize | meta.limit |

/api/site/pages reads pageSize; /api/site/blog reads limit. Both answer with meta.limit. Porting a listing from blog to pages by changing only the route therefore silently ignores the page size — you get the server default of 20 items, meta.limit reports 20, and nothing errors. The asymmetry is real and typed, not a wrapper bug; see PagesListParams in @reblu/site-client (src/types.ts).

Build

tsup emits dual ESM + CJS + .d.ts with source maps. Two guarantees are locked by test/use-client-smoke.test.ts against the real build output:

  1. Every client entry (./next, ./pagination, ./form, ./tabs, ./label) keeps its 'use client' directive prologue (ESM leading, CJS after esbuild's 'use strict'). tsup's treeshake is deliberately off — its post-esbuild rollup pass strips the directive and silently collapses the RSC boundary. splitting is off for the same reason: a shared chunk would move code out from under the directive.
  2. The root entry, and the dependency-free subpaths, are never forced into a client module.

test/esm-next-specifiers.test.ts locks a third: the ESM build imports next/link.js, never the bare next/link. Next ships no exports map, so plain Node ESM (a SITE's vitest) cannot resolve the bare subpath. The CJS build keeps require('next/link').

test/rsc-safety.test.ts adds the stronger claim: it loads each RSC-safe entry in a child process under --conditions=react-server and renders it there. ./pagination is deliberately NOT in that set — next/link runs React.createContext at module scope (via next/dist/shared/lib/router-context.shared-runtime), which the react-server build of React does not export. That is why the component is a client entry and its href builder is a separate, import-free one.

pnpm --filter @reblu/site-ui build   # emit dist/
pnpm --filter @reblu/site-ui test    # unit + build smoke