@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-reactThe 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 stateuseStanding()returns inside<SlyTifoProvider>, ornulloutside 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, ornulloutside 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 ofaward, 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.
useRefreshnever rejects — a failed read is reported throughuseStanding'sstatus, not by throwing. Don't treat the promise resolving as proof the refresh succeeded; await it to know the refetch is done, then checkstatus.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-workletsThey 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-skiafails to bundle: Metro stops withUnable to resolve module, naming a missing one. An app that never imports/card-skianeeds 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.jstest environment) andreact-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
enginewhen it mounts. A different value passed later is ignored; give the card a newkeyto switch.Ignored on the web, where the card already draws with CSS. Off React Native (web, Node, SSR),
/card-skiaresolves 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 thebrowserandimportconditions, notreact-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:onReadywaits 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 ownpackage.jsonand 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-svgWhat differs from the web:
- Drop shadows draw only when the app sets Info.plist
ReactNativeReleaseLeveltoexperimentalon React Native 0.85 and 0.86 (0.85 run on the Simulator, 0.86 read from source), orcanaryon 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 defaultstablelevel 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+xmlURI; anhttps://….svgURL 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.
