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

@slytrunk/sly-tifo-react

v0.4.0

Published

React hooks for @slytrunk/sly-tifo-client.

Readme

@slytrunk/sly-tifo-react

React hooks for @slytrunk/sly-tifo-client: a provider and five hooks for reading standing, awarding events, and reading collectibles, and a card widget that draws the end user's card in the page.

Install

npm install @slytrunk/sly-tifo-client @slytrunk/sly-tifo-react

The two packages are released together and always share a version number. Keep them on the same version: @slytrunk/[email protected] expects @slytrunk/[email protected].

Quick start

Create a client with @slytrunk/sly-tifo-client and pass it to <SlyTifoProvider>. Everything below reads from that one client.

import { createClient, API_BASES } from '@slytrunk/sly-tifo-client'
import { SlyTifoProvider, useStanding } from '@slytrunk/sly-tifo-react'

const client = createClient({
  apiBase: API_BASES.production,
  async getToken() {
    const res = await fetch('/api/sly-tifo-token')
    return res.json()
  },
})

function App() {
  return (
    <SlyTifoProvider client={client}>
      <Profile />
    </SlyTifoProvider>
  )
}

function Profile() {
  const { status, standing } = useStanding()
  if (status === 'idle' || status === 'loading') return <p>Loading…</p>
  if (status === 'error') return <p>Couldn't load your standing.</p>
  return <p>Tier: {standing?.tier?.label ?? 'none yet'}</p>
}

Keep the client stable: create it once, at module scope as above or in a useMemo. A new client is a new store, so the hooks start over and the card widget clears what it drew and refetches.

The hooks

  • useStanding() — the current standing, kept fresh by the provider. Returns { status, standing, refusal, error }.

    const { status, standing, refusal, error } = useStanding()
  • useOptionalStanding() — the same state useStanding() returns inside <SlyTifoProvider>, or null outside one. For a component that works with or without a provider; it never throws.

    const state = useOptionalStanding()
    if (state !== null && state.status === 'ready') {
      // standing is available
    }
  • useStandingSource() — an opaque identity that changes when the provider's client changes, or null outside a provider. Compare it with ===; nothing else about it is public. The card widget uses it to tell a refresh of the same user's standing from another user's.

  • useAward() — record an event against the client set up in <SlyTifoProvider>. Returns { award, pending }.

    const { award, pending } = useAward()
    await award({ action: 'watch-game' })
  • useRefresh() — re-fetch standing outside of award, for example after saving a profile change that affects the standing without going through an event. Returns a function you call to trigger the refetch.

    const refresh = useRefresh()
    await refresh()

    The promise resolves even when the refetch failed. useRefresh never rejects — a failed read is reported through useStanding's status, not by throwing. Don't treat the promise resolving as proof the refresh succeeded; await it to know the refetch is done, then check status.

  • useCollectibles() — the collectibles on the current standing. Returns an empty array while standing is loading, never a throw.

    const collectibles = useCollectibles()

Drawing the card

<SlyTifoCard /> draws an end user's card in the page, from the card data the API serves beside the card image, instead of showing the server-rendered image. It lives in its own entry, @slytrunk/sly-tifo-react/card; the package root does not load it. The same import works on the web and on React Native (see React Native).

import { SlyTifoCard } from '@slytrunk/sly-tifo-react/card'

Nothing else to install: the renderer is bundled. It needs React 19 or later (the hooks above still support 18), and importing the entry under an older React throws a clear error. That throw happens at module load, so a static import of the entry under 18 takes down the whole module graph that imports it, not just the card. An app that must also run on 18 can load it lazily, so the throw lands in an error boundary instead:

const SlyTifoCard = lazy(() =>
  import('@slytrunk/sly-tifo-react/card').then((m) => ({ default: m.SlyTifoCard })),
)

Three ways to use it

// 1. Inside <SlyTifoProvider>: the signed-in user's card. With one card
//    template this is all it takes.
<SlyTifoCard />

// 2. Pick a card by slug (one of standing.cards[]), optionally pinned to a season.
<SlyTifoCard slug="legend" />
<SlyTifoCard slug="legend" season="2026" />

// 3. Any card data URL. Needs no provider.
<SlyTifoCard src="https://cards.slytifo.com/c/{programId}/{cardId}/{slug}.json" />

Inside the provider, the card's data and image URLs come from standing, with a ?v= version so a changed card is fetched fresh: the cards[] entry's own version for a slug card, and the standing's card_version for the no-slug form. A season instead fetches …/{slug}/{season}.json, with no version. With src the URL is used as given, and season does not apply.

If nothing resolves, the widget renders fallback, or nothing, and reports no error. That covers no standing yet, a refused standing, a slug that is not in standing.cards[] (unknown, or its template unpublished), a program with more than one template and no slug, and a new client passed to the provider (another user's card is never left on screen). The one exception is a refresh of the same standing: while it is in flight or after it fails, the card already on screen stays (see Refresh).

Props

| Prop | What it does | | ------------- | --------------------------------------------------------------------------------------------------------- | | slug | The standing.cards[] entry with this slug. Without it, the standing's card_data_url. | | season | Pins to a season. Ignored with src. | | src | Any card data URL. Works without a provider. | | aspectRatio | Width over height, reserving the box before a src card's data arrives. | | onReady | Fires once per drawn card, after its images have settled. | | onError | Called with why the card did not draw: network, not-found, http, unsupported-schema or invalid. | | fallback | What to render when the card cannot draw, or when there is no card to draw. | | label | The card's accessible name. Default: the card's display name, or Card when that is empty. | | interactive | The card tilts toward the pointer or finger, with a glare and an intro. Default false. | | glare | The moving glare over an interactive card. Default true. | | intro | The sweep of light when an interactive card is shown. Default true. | | tilt | How an interactive card tilts: depth, speed, invert and lift. See below. | | touch | Web only: 'card' (default) or 'scroll', who owns a finger dragged on an interactive card. See below. | | gyro | Native only: the tilt follows the device. Pass DeviceMotion from expo-sensors. Ignored on the web. | | engine | Native only: Skia draws the motion and foil. Pass skiaEngine. Read once. Ignored on the web. |

Interactive cards

<SlyTifoCard interactive />

With interactive, the card tilts toward the pointer (web) or finger (native) and springs back to rest when it leaves or lifts. A glare follows the light across it, and when the card is first drawn, and each time a new version is shown, one sweep of light plays across it. glare={false} drops the glare and intro={false} drops the sweep. Without interactive the card renders exactly as it does without these props.

Shaping the tilt. tilt takes four optional fields. x is the left-right movement of the pointer, finger or device; y the up-down.

| Field | Does | Default | | -------- | ----------------------------------------------------------------------------------------------------- | ----------------- | | depth | Degrees the card turns at full tilt. One number for both axes, or { x, y }. 0 holds an axis flat. | { x: 11, y: 9 } | | speed | How fast the card follows, as a multiple of the default: 2 twice as fast, 0.5 half. | 1 | | invert | Turn the other way: the edge under the pointer rises instead of dipping. true, or { x, y }. | false | | lift | How much larger the card grows at full tilt: 0.04 is 4%, 0 none. | 0.04 |

<SlyTifoCard interactive tilt={{ depth: 18, speed: 1.5, invert: { y: true }, lift: 0 }} />

The fields apply to the pointer, the finger and the gyro alike. A change applies to a card already shown: depth, invert and lift at once, speed from the next movement. A value that is not a finite number, or is out of range (below 0, or 0 for speed), is ignored, and that field keeps its default. The glare stays under the pointer whichever way the card turns.

Touch on the web. By default every touch on an interactive card is the card's: a drag in any direction tilts it, and the page does not scroll or pinch-zoom from on top of the card. For a card that fills the screen, pass touch="scroll": a vertical swipe then scrolls the page (and ends the tilt), and only a drag that starts sideways tilts the card. Either way a long press on the card selects nothing, opens no image menu and paints no tap highlight. touch is ignored on native, where the card claims a finger as soon as it lands.

Reduced motion. Under the system's reduced-motion setting (the OS setting on native, prefers-reduced-motion on the web), the card holds still at rest: no tilt, no intro, no gyro. Until the platform has answered, the card is treated as reduced and does not move.

Gyro (native only). Pass a device-motion source and the tilt follows the device, measured from the angle it is held at when the first reading arrives (after the intro, if one is playing: the gyro waits for the sweep to finish). Held at that angle the card is unlit: its foil shows at the strength it has on a still card, with no glare and no lift. Tilting it about 10° in any direction lights it fully. DeviceMotion from expo-sensors fits as is; the widget does not depend on expo-sensors, so install it yourself, or pass any object with the same addListener shape (rotation.beta and rotation.gamma, in radians). A reading without a rotation is skipped (Android sends some before its rotation sensor first reports). Readings are smoothed, and a device held still moves nothing. A finger on the card overrides the gyro while it is down. gyro is ignored on the web and without interactive.

import { DeviceMotion } from 'expo-sensors'

const card = <SlyTifoCard interactive gyro={DeviceMotion} />

Taps on native. An interactive card claims the touch as soon as a finger lands on it, so a tap on the card does not reach a Pressable or Touchable around it. On the web, clicks still pass through to the host.

Android below 10 (API 29) draws the glare without blending: it shows as a plain white light over the card rather than lighting it.

Foil. A card's design can give a layer a foil: holo (thin rainbow streaks that catch the light; they don't show on pure white), metallic (a still metal tint under a moving glint) or sparkle (seeded glitter, rainbow or in the design's color, that catches the light as it nears its angle). Foil is part of the card, not a prop: there is nothing to turn on. It is drawn over the layer's own pixels only (the image's own pixels, text's glyphs, a shape's fill or ring) and blended with them. Soft image edges and baked-in shadows pick up the foil too; put a shadow on its own layer. Text drawn with the plain-text fallback shows no foil, and on native an SVG image asset gets foil clipped to its box rather than to its pixels. With interactive it moves with the light; otherwise the card shows it still, as the server image does, with holo and metallic at 60% of their full strength. On a collectibles strip the foil covers the whole strip, gaps between badges included. On native without the Skia engine, sparkle is static: it draws its glints at the rest light and doesn't follow tilt (animating it under a mask is too slow in react-native-svg). Web, the server image and the Skia engine animate it. Holo and metallic follow tilt everywhere. Android below 10 draws foil without blending, and on Android an image layer below full opacity draws its foil brighter than on the web and iOS.

A version of this package older than foil draws the card without it.

The Skia engine (native only)

By default a native card draws its foil with react-native-svg, which redraws on the CPU on every frame of a drag. On a mid-range Android phone a foil card then drags at about 20 frames a second. The Skia engine draws the same tilt, glare and foil with react-native-skia and Reanimated, on the UI thread, with no React render per frame, and animates sparkle. Dragging a foil card in a Release build, it ran at about 120 fps on an iPhone 14 Pro Max (about 74 without it) and about 77 fps on a Galaxy A25 (about 21 without it).

It is opt-in: an app that does not pass it installs nothing new, and its cards draw exactly as before. To use it, install the three packages:

npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets

They are optional peers: @shopify/react-native-skia 2.6.2 or later and react-native-reanimated 4.3 or later, which needs react-native-worklets. Expo needs nothing else: babel-preset-expo adds the worklets Babel plugin once the package is installed. A bare React Native app adds it to its babel.config.js:

module.exports = {
  // ...your presets
  plugins: ['react-native-worklets/plugin'],
}

Skip a step and it fails loudly, not by falling back to the default card:

  • Without the packages, a native app that imports /card-skia fails to bundle: Metro stops with Unable to resolve module, naming a missing one. An app that never imports /card-skia needs none of them.
  • Without the Babel plugin, a bare React Native app bundles, then fails when its first engine card mounts: nothing was compiled as a worklet, and a development build throws `runOnUI` can only be used with worklets.
  • Under Jest, whose React Native preset resolves the native build, the engine imports the real Skia and Reanimated: mock them with their own Jest setups, @shopify/react-native-skia/jestSetup.js (with its @shopify/react-native-skia/jestEnv.js test environment) and react-native-reanimated/mock.

Then pass the engine:

import { SlyTifoCard } from '@slytrunk/sly-tifo-react/card'
import { skiaEngine } from '@slytrunk/sly-tifo-react/card-skia'

const card = <SlyTifoCard interactive engine={skiaEngine} />
  • Read once. The card reads engine when it mounts. A different value passed later is ignored; give the card a new key to switch.

  • Ignored on the web, where the card already draws with CSS. Off React Native (web, Node, SSR), /card-skia resolves to a stub with no Skia or Reanimated in it, so code shared with a web or Node build can import it. Expo web is Metro too, and gets the stub: it resolves the browser and import conditions, not react-native.

  • A still card (without interactive) draws its foil through the engine too, held at rest.

  • Sparkle animates with the light, as on the web.

  • An image layer's foil may appear a moment after onReady: onReady waits for the photo, not for the copy of it the foil is masked by. The default path does the same.

  • An SVG image asset gets foil clipped to its box, as on the default path.

  • React commits. A drag makes no React commit per move, but up to three around it: one or two when the finger lifts and one shortly after. That is Reanimated's static feature flag FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS (on by default). An app that wants it off sets it in its own package.json and rebuilds the native app:

    {
      "reanimated": {
        "staticFeatureFlags": { "FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false }
      }
    }

Fallback

When the card cannot draw, the default is the server-rendered image of the same card, so the widget degrades to what you showed before it. Pass fallback to replace that with your own element; onError still fires either way. For a src card the image is the same URL with .json replaced by .webp.

The image is asked for at the width it is shown at: ?w= on its URL is set to the box's width times the device pixel ratio, rounded up to 300, 600, 900 or 1200, and the image service returns the copy for that width, or the full render when the render is no wider. A box wider than 1200 device pixels gets the full render, with no w. A w already on the URL is replaced. The image appears once the box has a width, and loads again only when the box is resized into a different one of those widths.

Accessibility

The card is one image to a screen reader (role="img"), named by label: the end user's display name by default, or Card when it is empty. The default fallback image carries the same name. Pass label when the page has a better one, such as "Your season card".

Sizing

The card fills the width of its container and keeps its proportions, so size it by wrapping it:

<div style={{ width: 360 }}>
  <SlyTifoCard />
</div>

The box reserves its height before the card arrives from the size standing reports, or from aspectRatio for a src card, so the page does not jump when it draws. With neither, the box has no height until the card draws; if the card fails, the default fallback image gives the box the image's own proportions once it has loaded (the box first needs a width, which it has whenever its container does).

Server rendering. The widget renders on the server, but react-native-web's class names arrive without their style sheet until the client mounts, so the server HTML is unstyled for that moment. The box's size is set inline, not by class, so the layout holds and nothing shifts when the styles land.

Refresh

On the web the widget fetches with cache: 'no-cache': the browser revalidates, and an unchanged card is a 304 with no body. When a standing refresh (for example after useAward()) brings a new card_version, the widget fetches and redraws the new card. The old card stays until the new one's images have loaded (at most 3 seconds), so it changes in one step rather than drawing its images in late. If a refresh fails, the card already on screen stays; it is not blanked.

On React Native the widget sends no cache option: React Native's fetch would turn no-cache into a cache-busting query that defeats the CDN and the ETag. A new card still arrives fresh, since its ?v= changes with each version.

React Native

The /card entry draws natively on Expo SDK 56 / React Native 0.85 or later. Metro picks the native build through the react-native export condition, so the import is the same as on the web. The native build bundles the renderer but not React Native or react-native-svg: install both in the app (react-native 0.85 or later, react-native-svg 15.15 or later). They are optional peers, so a web app installs neither.

npx expo install react-native-svg

What differs from the web:

  • Drop shadows draw only when the app sets Info.plist ReactNativeReleaseLevel to experimental on React Native 0.85 and 0.86 (0.85 run on the Simulator, 0.86 read from source), or canary on 0.87+ (read from the 0.87.0 source). That key turns on a whole set of React Native feature flags, not just this one, so it is your app's call. At the default stable level the card draws without its drop shadows; inner shadows draw either way.
  • Grain is smoothed rather than pixelated.
  • SVG images draw as vectors, but without a tint.
  • Text the shaper refused (right-to-left, an unmapped character) draws in the system font.
  • An end user's SVG image URL draws only as a data:image/svg+xml URI; an https://….svg URL draws nothing on native.

These were checked on the iOS Simulator; Android has not been run yet.

Foil drags slowly on the default native path, most of all on Android. The Skia engine is the opt-in fix.

Bundle size and react-native-web

The /card entry is 74,457 B brotli (about 73 KB). It bundles react-native-web and react-native-svg's web build, so an app that already runs react-native-web gets a second copy of it. Importing only the package root costs none of this.

Learn more

For the client itself — the five API methods, refusals, error handling, and troubleshooting a persistent 401 — see @slytrunk/sly-tifo-client.