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

@scopetickets/avatar

v0.2.1

Published

The Scope Tickets avatar — the value scheme, the geometry, and the presentations. Web, presentational, identity-as-props.

Readme

@scopetickets/avatar

The Scope Tickets avatar: the value scheme a stored avatar is written in, the geometry the design specifies, and the presentations built from both.

Web, React, presentational. No auth, no store, no network, no images.

import { Avatar, GUEST_AVATAR_VALUE } from '@scopetickets/avatar';

<Avatar subject={{ kind: 'user', avatar: profile.avatar, assets }} size={25} />
<Avatar subject={{ kind: 'user', avatar: GUEST_AVATAR_VALUE, assets }} />

Vocabulary, matching the design system: the subject is what the avatar is of, the accessory is what sits in the corner, the notch is the hole cut for it, and the sizes are named for their spacing token — $25 (a 100px box) and $10 (40px).


The contract

Three rules. They are the reason this is a package and not a component in whichever app needed it first, and a consumer that breaks one will find the package unusable rather than merely awkward.

1. It takes identity as PROPS. It never reaches for auth itself.

No store import, no session hook, no context lookup for who the user is. The avatar value arrives as a string; where that string came from is the caller's business.

This follows the standard that providers own third-party and backend interactions and components prefer a callback passed down over reaching into global state. It is also just arithmetic: a package that called useAuthStore internally would be unusable by any consumer that does not have that store — which includes every embedded surface, and specifically includes a booking widget's island, which imports nothing from its host by design.

The same rule is why the package ships no images (see AvatarAssets below).

2. The consumer can own the circle. The package renders what goes INSIDE it.

Pass fill and the avatar takes 100% of whatever box it is dropped into, letting that box own the size, the circle and the clip:

<span className="scopetickets-island-avatar scopetickets-island-avatar--lg">
  <Avatar subject={{ kind: 'user', avatar: value, assets }} size={25} fill />
</span>

That mode is not a convenience. Where an avatar is animated between two positions, it is a single element carried between them — drawn once in an overlay above both planes and moved from where it was to where it is going. The circle has to belong to the node the carry layer moves, which means it cannot belong to this package.

Nothing inside a filling avatar is measured in pixels; every length is a percentage of the box and every mask is an SVG scaled to it, so the same avatar is correct at 40, at 100, and at every size a carry animation passes through.

size still chooses the geometry in fill mode, because the proportions inside the box differ between the two designs — fill is not a third size.

Without fill the avatar sizes itself to scale and rounds itself, which is what most consumers want.

An avatar WITH AN ACCESSORY needs a parent that does NOT clip. The accessory sits flush in the box's bottom-right corner, outside the subject's own circle — a parent clipping to a circle of the whole box will cut it off. Clip the parent for the plain presentation; give an accessorised one a square box.

3. Web, not React Native.

Ported from the Unipass Expo app (commit 80a18ea) — the decisions, not the imports. MaskedView became a CSS mask-image, expo-image became <img>, StyleSheet became inline styles, and the .svg mask assets became generated data URIs.

Inline styles rather than a stylesheet: a consumer never has to wire up a CSS import to make an avatar look right, and one that imports nothing from its host could not. There is no styles.css entry and no class names to collide.

No CSS custom properties are read or written. In particular the package does not touch the --scope-* namespace, which belongs to the SDK and is explicitly not a public API.


The value scheme

An avatar is a string, and its prefix says what it is.

| Value | Meaning | | --------------------- | ------------------------------------------------------ | | guest:placeholder | Signed out. A guaranteed guest treatment. | | default:<id> | A real signed-in user's stock photo, chosen from a set | | initials:<initials> | Programmatic, no image behind it | | anything else | A URI |

GUEST_AVATAR_VALUE; // 'guest:placeholder'
defaultAvatarValue(id); //  → 'default:<id>'      defaultAvatarId(value)  → id
initialsAvatarValue('AW'); // → 'initials:AW'     avatarInitials(value)   → 'AW'

resolveAvatarSource(value, assets); //        → a descriptor, always
resolveAvatarBlurredSource(value, assets); // → a url, or undefined

resolveAvatarSource is the only place a value is interpreted. Every presentation forwards whatever string it was handed straight into it.

Why guest:placeholder is a value and not null

Because reading a profile store while signed out still resolves to whatever profile happens to be persisted — a stale one from a previous session — rather than a guaranteed guest treatment. So every signed-out call site passes the sentinel EXPLICITLY instead of profile?.avatar, and the sentinel is what makes "signed out" a state the renderer can actually see.

Keep that property. It is the whole reason the sentinel exists.

It is also distinct from default: — a default: is a real signed-in user who has not set a photo; guest: presupposes no profile at all. The two must never resolve to the same picture, or a guest looks "signed in with no photo". src/avatar-value.test.ts is the regression coverage for exactly that.

An absent value is a fault, not a state

Every Scope profile carries one of the four forms, so null — or a value the scheme does not recognise — is not "a user with no photo". It means the field never arrived: a mis-wired stub, a request that failed, a response shape that changed. Nothing renders it as a resting state, and it is kept rather than smoothed over precisely so it stays a flag.

It draws in its own colour, apart from the other one, because "your stock set is short an id" and "your data did not load" are different bugs with different fixes and one red square cannot say which:

| data-scopetickets-avatar-missing | means | | ---------------------------------- | ------------------------------------------------------ | | photo | a default:<id> outside the supplied stock set | | unset | no value at all — the profile field did not arrive |

guest is not in that table any more. It was, and hatching it was wrong: the fill reports gaps somebody will close, and signed out is a state the product has on every surface — for a buyer with no account it is the avatar for a whole purchase. It is drawn now; see below.

AvatarAssets.placeholder exists because Unipass genuinely has a signed-in-with-no-photo state. Supplying it here would mask the flag, so do not, unless the consuming product actually has that state.

There is no variant prop, and stubbing auth must not add one

The tier is not a choice a caller makes — it is a reading of the value, and resolveAvatarSource is the only thing that does the reading. So a consumer that wants to render a particular treatment says so by supplying the corresponding value, never by selecting a variant:

GUEST_AVATAR_VALUE; //           the signed-out treatment
defaultAvatarValue('3'); //      a stock photo
initialsAvatarValue('CB'); //    drawn initials
('https://…/photo.jpg'); //        a URI
null; //                         signed in, no photo set

This matters most while a consumer's auth is stubbed, which is when a variant prop looks like the obvious shortcut. It is a second way to say something the value already says, and it is the one that rots: the stub renders through a branch production never takes, so the path being tested is not the path that ships. A stub that returns a value string is the same code as the API returning one.

The signed-out treatment is drawn, not supplied

guest:placeholder renders GuestAvatar when the consumer supplies no assets.guest — a blue gradient disc under the pale PersonInCircle figure, ported from Unipass's own GuestAvatarPlaceholder.png and reading its colours off that file. The mark's geometry comes from @scopetickets/icons, the one dependency this package has besides React, so there is no transcribed path here to drift from the set.

A consumer's own assets.guest still wins. This is the floor: the package guarantees a real guest treatment rather than guaranteeing only that guest is distinguishable from signed-in.

Where the pixels come from

The package owns the scheme and the geometry; the consumer owns the images, with the signed-out treatment above as the one exception.

Demo art for the bench lives in assets/ — a stock set named by the ids the backend issues, and a guest mark. It is not published: files is ["dist"], so nothing under assets/ reaches a consumer's tarball. Note there is deliberately no placeholder image in it; see the bench for why.

const assets: AvatarAssets = {
  guest: '/img/guest.png', //  the signed-out treatment
  placeholder: '/img/user.jpg', // a signed-in user with no photo set
  defaults: {
    // the stock-photo set, by id
    '3454b415-…': { normal: '/img/3454b415.jpeg', blurred: '/img/3454b415-b.jpeg' },
  },
};

Every field is optional. An asset the consumer did not supply renders as visibly unfinished — flat, hatched, and obviously a hole — rather than as something else that happened to be available. A placeholder that looks finished is a lie about what has shipped: nobody files it, and it reaches production. The two variants of that fill are different colours, because they are different bugs. guest is the exception, and only because it is not a bug.

resolveAvatarBlurredSource returns undefined rather than falling back — it feeds page backgrounds, and decoration derived from a placeholder is worse than no decoration.


The presentations

import { Avatar, CountBadge } from '@scopetickets/avatar';
import { QrCodeIcon, ArrowsCirclingIcon } from '@scopetickets/icons';
import { CountryFlag } from '@scopetickets/icons/flags';

// The five subjects
<Avatar subject={{ kind: 'user', avatar: value, assets }} />
<Avatar subject={{ kind: 'organiser', src: markUrl }} />
<Avatar subject={{ kind: 'event', src: artUrl }} />
<Avatar subject={{ kind: 'ticket', src: artUrl }} />
<Avatar subject={{ kind: 'country', content: <CountryFlag country="Ireland" /> }} />

// The three accessory shapes, which decide the notch
<Avatar subject={s} accessory={{ shape: 'square', content: <QrCodeIcon /> }} />
<Avatar subject={s} accessory={{ shape: 'circle', content: <ArrowsCirclingIcon /> }} />
<Avatar subject={s} accessory={{ shape: 'circle', content: <CountBadge value={3} /> }} />
<Avatar subject={s} accessory={{ shape: 'rectangle', src: artUrl }} />

accessory absent is the design system's None: no notch is cut, and the subject grows to fill the whole box.

Why the content-bearing subjects take a URL or a node

A country subject is a flag, and the flag set is a megabyte of coats of arms — Spain's alone is half of it. An avatar package that imported it would put Spain's arms in the bundle of every consumer that only ever draws a photo. So the caller passes @scopetickets/icons' own CountryFlag, and this package keeps its one job: frames, masks and notches.

The same holds for accessories. An icon accessory is any node, so the icon set is the caller's dependency, not this one's. The two packages compose without either importing the other.

The user subject is the exception, because its content is not a node — it is a stored value, and which tier renders (photo, initials, stock photo, guest, unset) is derived from that value rather than chosen. That derivation is the scheme below, and it belongs here.

One mask, with the notch as a parameter

Native shipped one masked-avatar component per notch shape, each with its own hand-drawn .svg. avatar-mask-hero.svg cut a circular notch — correct for the account overlay's round badge, wrong for the island's rounded-square one, where it left a round gap around the badge no matter how the badge itself was drawn. The fix was a second asset, and then a third, and then several masks that could disagree about the same geometry.

They did disagree. So the notch is not a drawing here at all — it is one rule, applied to the accessory's own geometry:

notch radius = accessory radius + padding

A circle obeys it too, its radius being half its width. Nothing is stated per shape, so nothing can drift, and no shape can become the exception. Both files break it in the same place — the square notch, drawn at the accessory's radius ungrown — and docs/composition.md records what each drew and what is rendered instead.

Sizes

Two, and they are independently specified, not a scale of one another. Do not derive one from the other, and do not add a third by interpolation — each came off its own Figma frame.

$25   box 100   frame 84 (4:5 subject 80x100)   cutout 56 @ 4   gap 4
      slots: circle 48 · square 48 r12 · rectangle 48x60 r8
$10   box  40   frame 32 (4:5 subject 32x40)    cutout 24 @ 2   gap 2
      slots: circle 20 · square 20 r4  · rectangle 16x20 r4

Note the rectangle slot: a third of $25's width where the box is four tenths of it. That is the rule above, in one number.

A 4:5 subject does not make the avatar portrait — the box is always 1:1. The artwork fills the box's height and centres in its width, giving up the column the accessory occupies.

Which entity goes in the frame

The frame is the subject of the sentence a surface is making; the notch is what qualifies it. Getting that backwards changes what the avatar says, so it is a rule rather than a preference — see docs/composition.md, which also records what this package calls things and why it differs from the reference file's own variant names.


Motion

Adding, removing and changing an accessory is one transition, not three. avatarTransition is a headless model: it takes a from and a to accessory shape and returns the frame, the slot, the hole and both opacities at a normalised time. It owns no clock and touches no DOM, so the consumer that owns the box (contract #2) also owns the timing and can drive it from rAF, from a gesture, or from a test.

const t = avatarTransition({ geometry: GEOMETRY_25, frame: 'circle', from: null, to: 'square' });
const { frame, slot, notch, incoming, outgoing } = t.at(elapsed / t.durationMs);

Three things it is worth knowing the reason for:

The slot is what moves; the hole is grown out of it. notch radius = accessory radius + gap is applied every frame rather than tweened alongside, so the gap holds mid-flight by construction instead of by two tweens happening to agree. Every notch is already a rounded rectangle — a circle's radius is half its side — so morphing between any two shapes is five numbers, and "no accessory" is those same five collapsed to a point.

A removal is not an addition reversed. The subject has to wait for the accessory to actually be gone, and how long that takes is exitStretch, not accessoryTrim — so the delay is derived from the exit's real duration, and a removal's clock is longer by exactly that delay rather than the subject being squeezed into what is left.

A departure cannot pop. Overshoot on a growing box is a bigger box; on a shrinking one it is a negative box. So a departure is given the spring's travel stretched over a duration it can show, instead of the settle it has no room to show. AVATAR_TIMING holds the three tuned values.

The ticket frame is the one shape that cannot be tweened — it is a real bezier silhouette — so TweenableFrame excludes it and a ticket changing shape needs a cross-fade.


Fonts and colour

The initials avatar draws its type with font-family: inherit — the package ships no fonts (that is the SDK's job) and naming one it does not ship would render a fallback while claiming otherwise. Set the family on any ancestor.

Its gradient and text colour are constants, not theme lookups: they are the same value in both UI schemes in the design, so a theme would be a theme of one.


Development

npm install
npm test          # vitest, node env — components asserted through renderToStaticMarkup
npm run typecheck
npm run build     # tsup → ESM + .d.ts, react external
npm run ci        # all of the above

React 18 is a peer dependency. A bundled second React breaks hooks, and consumers pin their own (TwinkleTown pins ~18.3.1).

Three entry points

The root is the avatar with its renderer — Avatar, GuestAvatar, CountBadge and the rest, drawing to the DOM. ./core is everything that DECIDES what an avatar looks like, with nothing that draws it:

import { AVATAR_GEOMETRY, avatarTransition, resolveAvatarSource } from '@scopetickets/avatar/core';

The value scheme, the geometry, the masks, the motion model, the ticket shape and the colours — for a React Native renderer, a canvas, or a server. It needs no peer dependencies at all.

This is not a split: every module behind it was already free of React and of the DOM, and was simply unreachable except through a barrel that also exported five components. ./core and the root share one copy of the model, and the build refuses a ./core that reaches a renderer or duplicates a value.

The colours are the one addition to what was public. tokens.ts was internal, so a consumer could take the geometry and the masks and then have nothing to fill them with — a shape without its colour is not a drawable avatar.

./native is the avatar drawn by React Native, from that same model:

import { Avatar, CountBadge } from '@scopetickets/avatar/native';

<Avatar
  size={25}
  subject={{ kind: 'user', avatar }}
  accessory={{ shape: 'circle', content: badge }}
/>;

react-native, react-native-svg and @react-native-masked-view/masked-view are optional peers — a web consumer installs none of them and never resolves this entry. masked-view is there because CSS masks arbitrary content and React Native has no equivalent: the same shapes become an Svg mask element instead. This entry also needs @scopetickets/icons@>=0.2.0, because its GuestAvatar reads the mark from @scopetickets/icons/paths rather than pulling a DOM renderer in for two path strings. The root is unaffected.

Two things arrive as props there that the web reads from the platform, because there is no cascade and no media query to read them from: fontFamily, since React Native text inherits no family, and reduceMotion, since the answer lives behind an AccessibilityInfo subscription this package will not open inside an avatar.

Getting it into a consumer

The package is published publicly on npm:

npm install @scopetickets/avatar

React and @scopetickets/icons are peer dependencies, so consumers retain one copy of each.