pack-opener-js
v0.5.9
Published
Interactive pack-opening animation rendered by PixiJS, for React Native and the web
Maintainers
Readme
pack-opener-js
Interactive pack-opening animation rendered by PixiJS. One scene, two hosts: the web and React Native (inside a WebView).
The pack hangs there floating, light crossing its foil, a comet mimes the gesture, and the seal is cut by an actual finger — the tear follows the path the finger took, not a straight line between two points, glowing and throwing sparks as it goes. The lid flies off, light pours out of the pack, the card rises face down, gathers itself in its rarity colour and turns over in perspective onto its stand.
npm install pack-opener-js
# web: also install pixi.js
# React Native: also install react-native-webview react-native-reanimatedWeb
import {createPackOpener} from 'pack-opener-js';
const opener = await createPackOpener(document.querySelector('#stage'), {
assets: {pack: {url: pack.url}, card: {url: card.url}},
theme: {glow: rarityColor},
}, {
onEvent: event => console.log(event.type, event),
});
opener.autoSlice(); // open it without a gesture
opener.shuffle(); // carousel: spin the ring on
opener.reset(); // put it back together
opener.setOptions({assets: {pack: {url}}, motion: {speed: 2}}); // retune live
opener.destroy();The engine is imperative on purpose — any framework can call it. For React there is a component over it:
import PackOpener from 'pack-opener-js/react';
<PackOpener
options={{assets: {pack: {url: pack.url}}, theme: {glow: rarityColor}}}
onOpenComplete={callOpenApi}
onRevealComplete={showResult}
/>;Input goes through pointer events, so the cut follows a finger, a stylus or a mouse with no separate code path.
React Native
import PackOpener from 'pack-opener-js/native';
<PackOpener
ref={openerRef}
options={{
assets: {pack: {url: pack.url}, card: {url: card.url}},
theme: {glow: rarityColor},
}}
disabled={busy}
hint={<Text>Slice the seal to open</Text>}
topSlot={title}
bottomSlot={actions}
renderFallback={() => <MyImage uri={pack.url} />}
onHaptic={intent => Haptics.trigger(intent)}
onInteractionStart={hideChrome}
onOpenComplete={callOpenApi}
onRevealComplete={showResult}
/>;ref exposes autoSlice(), shuffle() and reset(); disabled covers
ignoring input.
The host owns everything around the animation. The package plays no haptics and
ships no typography: it reports intents (light / heavy / success) and
renders whatever hint, topSlot and bottomSlot are given, positioned
against the geometry the scene reports back — so slots sit on the revealed card
on any screen size.
Artwork is fetched by the WebView, so the pack and card URLs must be reachable from it. Cross-origin images need CORS headers, or the host can hand over data URLs instead.
Options
Options can change while the animation is on screen. Pass a new object (React
Native and React) or call setOptions on the handle (web) and the running scene
picks it up — durations, colours and gesture thresholds take effect at once, and
anything baked into a texture, such as the card's colours or its size, is rebuilt
on the next reset() rather than mid-ceremony. Two things do need a new scene
and cannot be retuned: a different variant and different pack artwork. The
wrappers remount by themselves in that case; setOptions returns false so a
direct caller knows.
Anything left out falls back to the chosen preset, field by field: a theme
with only glow set keeps the preset's card colours. PackOpenerOptions is the
full, commented surface — in short:
Well over two hundred fields, in the groups below — enough to build a noticeably different ceremony out of the same mechanic:
| Group | What it covers |
| --- | --- |
| variant | Which animation mechanic runs: slice, burst or carousel. |
| preset | Named set of numbers for that mechanic: classic for slice, charged for burst, showcase for carousel. |
| assets | Pack and card artwork, how long to wait for the card, and — for carousel — the card's facts ({label, value} pairs) and its tier badge ({label, color}), which it tells before the card is shown. |
| theme | Background, rarity glow, rim, bloom, beam, sparks, the light of the cut, hint, the card back's colours and print, corner radius. |
| motion | Every shared duration, plus speed as one multiplier over all of them. How the lid is thrown, tumbled and faded; how far the emptied wrapper sinks; how thin the card goes edge-on in a flat turn and how it is shaded; the beam's width, glow, tip and tail; the opacity of all three glows; the card's own flourish. |
| interaction | slice only. What counts as a swipe: activation distance, commit fraction, trail sampling, cut gap and raggedness, the band it may travel through, how the blade runs out, how often progress is reported, and the arc a programmatic cut follows. |
| layout | Where the pack sits and how much of the stage it takes; the card's size and widest allowed ratio; the size and softness of the three glows. |
| rest | Where the revealed card and its stand come to rest, given outright in css px (card box, pedestal left/width/bottom) — for a host whose own layout decides; absent, layout decides. |
| hint | slice only. The comet that mimes the swipe: position, sweep, head, tail, tail opacity and falloff, cadence. |
| slice | slice only. The pack's float, the light crossing its foil and the glow round its outline; the seam, the light under the blade and the sparks off it; the flare, the spray and the jolt when the seal gives, and the light pouring out; the card's charge — beam laps, halo, printed star, sway, tremble — its turn in perspective, the landing with its push, knock and glints, and the embers after it. |
| charge | burst only. How long a full charge takes and how fast it bleeds away; the shudder and squeeze of a pressured pack; the heat, halo, bloom and progress arc it lights up with; the sparks pulled in from outside; the breath of an untouched pack. |
| carousel | carousel only. The ring: how many copies, its radius, how far they turn with it and how they gather toward the front, the lens and the eye's height, how a copy fades and shades as it turns away, the hover; the idle drift, the shuffle, the finger's turn and the settle; the drop, the rise and how large the held pack grows; the float; the dissolve, the pacing of the facts, the banner's slide, hold and tilt, the flip; the floor reflection. |
| burst | burst only. The blast: flash, the grid the foil is torn into and how uneven that cut is, when the shards start burning as sparks and when they fade, plus the spikes, shock rings, debris streaks and glitter thrown off with them. Then the beat of quiet, and the card's arrival: the dust cloud, the grid the artwork is cut into, how far out its pieces start and how they are staggered, when the finished card comes up underneath them, the landing push and the ring that goes out with it. |
| performance | Frame caps for moving, idling and sleeping; ceiling on the device pixel ratio; multisampling. |
| sound | The ceremony's sound, off unless enabled: its volume, the player's own muted, the pull's badge, which decides how hard the landing hits, and the host's own samples laid over the baked cues. See Sound. |
What is deliberately not in here: easing curves, and the order of the phases themselves. A different sequence of events is a different mechanic — that is what a variant is for, and adding one does not touch this shape.
Two levels on purpose. A variant is a mechanic with its own scene module; a preset is a set of numbers for one variant. New styles arrive as variants, new looks as presets, and neither changes the shape above. Groups marked above as belonging to one mechanic are only filled in when that mechanic runs, so a host cannot spend an afternoon tuning knobs the running scene never reads.
classic is the animation as it shipped in the app this was built for — every
number in it was measured against the reference recording, so it is the baseline
other presets are judged against.
The three mechanics
slice — the seal is cut with a finger. The cut follows the trail and
glows behind the blade, sparks flying off it; when it gives the lid tears
away, light pours out of the pack, the emptied wrapper sinks and the card
rises out past the lip face down, a plain card of white light. It gathers
itself — a beam in its colour lapping its edge, the halo behind it filling
with that colour — and turns over in perspective onto its stand, glints
catching on the artwork as it lands. It asks for a small piece of skill and
rewards a confident swipe.
burst — the pack is held under a finger and the pressure builds: it
shudders, squeezes, heats up from the inside and a line closes around it showing
how far the charge has come. At full charge it goes off: a flash, spikes of
light, shock rings, and the wrapper torn into a few hundred shards that burn out
as they fly, with debris streaking out and glitter left hanging in the air. Then
a beat of quiet — held, not empty, the glitter is still drifting. The card does
not slide in: dust gathers where it will be, resolves into pieces of the
artwork, and they fly into place until the card is whole. What marks the
landing is the card itself — its outline swells and a highlight runs across the
artwork — rather than anything thrown at it. It asks for nerve rather than aim,
and the wait is the point.
Both halves are cut by the same knife. The artwork — wrapper first, card after — is re-cut into a mosaic: one grid of nodes pushed off true, every edge torn along points both of its cells share. Pieces that interlock are what let the wrapper look whole until it bursts and the card arrive as a card rather than as a grid with gaps in it.
carousel — copies of the pack stand on a turntable, each turned with
the ring and facing out, seen from in front and a little above and mirrored
in the floor: the front one lit, the ones beside it turned away in
perspective, the two round the back seen from behind between them. A shuffle spins the ring round,
settles it on another copy and chooses it; a finger turns it, and let go it
coasts and settles on the nearest. A tap on a copy chooses it too: it drops off the ring as its neighbours fade and rises alone to
centre stage, where it floats until it is tapped again — the host opens the
pack server-side in between, and the float lasts as long as that takes,
nothing looping. The second tap dissolves the pack and the pull is told
before it is shown: its facts fade in one under the other, then a pill in the
tier's colour, and for a tier worth shouting about a banner slides across the
stage with the tier's name running along it. Then the blank card flips over
into the artwork. It asks for nothing but a choice, and the telling is the
point: the reveal is read before it is seen.
The mechanics share no part of their finish, on purpose: a second animation
that ends in the same spinning card back is not a second animation. slice
charges the card face down and turns it over in perspective; burst tears the wrapper into
pieces and then builds the card out of pieces; carousel tells the card in
words and then flips it over. What they do share is the card object, its
stand and the events, so a host swaps one for another with a single option
and changes nothing else.
Three numbers in charged carry most of its feel: charge.holdMs, the length
of the anticipation; burst.beatMs, the quiet between the blast and the dust —
borrowed from slot design, where the gap before the last reel stops is what
makes a result feel considered rather than rushed; and
burst.assembleStagger, which decides whether the card fills in from the
middle outwards or arrives as one flat sheet.
Performance
A WebGL canvas the size of a phone screen, redrawn sixty times a second, is what makes a device hot — and the scene spends most of its life with nothing new to draw: waiting for a finger, or holding a card that has already settled. So it throttles itself in three steps, all of them yours to change:
performance: {maxFps: 60, idleFps: 30, sleepFps: 1, resolutionCap: 2, antialias: true}maxFps applies while something moves, idleFps while the pack just hangs
there looping the hint, sleepFps once nothing changes at all. Commands and the
first touch always restore the full rate immediately, so a sleeping scene never
feels stuck. resolutionCap is the ceiling on the device pixel ratio the canvas
is drawn at — phones report 3 and up, and each step multiplies the pixels the GPU
shades for a scene made of soft glows, where the difference is hard to see.
Turn the caps up if you are on a desktop and want it perfectly smooth; turn them down further on low-end phones.
Sound
The ceremony can be heard: the foil tearing under the finger, the lid coming off, the sweep and the impact as the card lands, the burst's blast, the carousel's clicks, whooshes and chime. Each mechanic has its own score, played on the scene's own events with Web Audio wherever the scene runs — the browser on the web, the WebView on React Native — so a host wires nothing. It is off by default; turn it on with an option:
sound: {enabled: true, volume: 0.8, muted: false, badge: 'Big Hit'}volume is the master level (0.8 when left out). muted is for the player's
own mute button: pass the new options and what is playing fades out, while the
score keeps its place — lift the mute mid-ceremony and the next cue is heard.
badge is the pull's badge as the platform names it (Grail, Chase, Big Hit,
Epic, Rare); the bigger the pull, the harder the landing hits. It may arrive
late, with the card, and falls back to assets.card.badge's label.
A host that already scores the ceremony itself from the events (onPhase,
onProgress, onTimeline and the rest) keeps sound off, or drops its own
score when it turns this one on — with both, every cue plays twice.
The sounds are baked into the package, so there is nothing to host or fetch.
On the web they sit in a chunk of their own that is only loaded once sound is
on; the React Native wrapper lets its WebView start audio without a touch
inside the page, so a pack opened with autoSlice is heard too. On iOS the
session is ambient where the browser lets it be set: the ringer switch silences
the ceremony and the player's music keeps playing. The files and their
licences are listed in assets/sounds/CREDITS.md; replace one there and run
pnpm build:sounds.
A host with sounds of its own lays them over the baked ones by cue name, a URL
or a data: URI each:
sound: {
enabled: true,
samples: {'reveal-sweep': '/sounds/sweep.mp3', impact: '/sounds/impact.mp3'},
}The cues are the keys of assets/sounds/manifest.json. The files are read
once, when sound is first on; one that will not load leaves the baked cue in
place. In the React Native WebView only data: URIs are sure to load. The
score's timing is tuned to the baked files — reveal-sweep starts 2 s before
the card lands, impact 0.94 s before it hits, blast 0.57 s before the
flash — so a replacement with a different run-up lands off the beat.
Events
Both hosts see the same sequence, as callbacks on the web and as props on React
Native: the gesture starts, the pack commits to being cut, the lid comes off
(onOpenComplete — the moment to call an open API, the reveal that follows buys
the request its time), and the whole ceremony ends (onRevealComplete). The
carousel commits on the first tap and opens on the second, so a host that
wants the open API called at the commit — and its facts and badge passed in
with setOptions before the second tap — has the whole float to do it in.
Requirements
- Web:
pixi.js^8.3 (sliceturns its card andcarouseldraws its ring withPerspectiveMesh) and a WebGL-capable browser.react^18 or ^19 for the/reactentry. - React Native: 0.79 or newer (the package is resolved through
exports), plusreact-native-webviewandreact-native-reanimated. Pixi is not needed — it is compiled into the scene bundle and never reaches the host'snode_modules.
Adding an animation style
A variant is a mechanic — how the pack opens and what the reveal looks like. A preset is a named set of numbers for one variant. Hosts pick both:
createPackOpener(el, {variant: 'slice', preset: 'classic', assets: {…}});
createPackOpener(el, {variant: 'burst', preset: 'charged', assets: {…}});A variant declares itself and nothing else in the package changes — not the engine, not either wrapper:
// src/core/variants/tear/index.ts
export const tear: VariantModule = {
id: 'tear',
presets: PRESETS, // its own numbers; `resolveOptions` reads them
defaultPreset: 'classic',
create({app, texture, options, emit}) {
return new TearScene(app, texture, options, emit);
},
};create gets a live Pixi app, the loaded pack texture, options with every field
already filled in, and emit. It returns the instance the engine drives:
update(deltaMS), the gesture (onDown / onMove / onUp), autoSlice,
reset, setEnabled, setCardTexture, a rect telling hosts where the pack
was drawn, and an optional destroy. Full definitions with comments are in
src/core/variants/types.ts.
Register it in src/core/variants/index.ts and add its name to VariantName in
src/core/config/types.ts. Defaults resolve through the variant, so a new
mechanic never inherits numbers that were tuned for a different one.
What comes out of the pack is not part of a mechanic: the card, its glows and
the beam around it live in variants/shared, and a variant only picks how the
card enters — place + emerge for a blast, park + slide for a cut. Option
groups that belong to one mechanic are declared optional in ResolvedOptions
and resolved only when its preset carries them.
Working on the package
src/core/ the engine: createPackOpener(element, options) → handle
config/ the public option surface: types, resolve, bridge protocol
runtime/ shared helpers (colour parsing)
variants/ one folder per animation style
types.ts what a variant must provide
index.ts the registry
shared/ what every mechanic reveals: geometry, textures, the card
slice/ cut the seal: declaration, presets, scene, wrapper, hint
burst/ charge and detonate: declaration, presets, scene, pack,
shreds, and the card assembled out of particles
carousel/ choose and be told: declaration, presets, scene, the ring
of copies (turntable) and the story told in text
src/react/ React component for the web, over the engine
src/native/ React Native wrapper: WebView + bridge + geometry-driven slots
webviewEntry.js what gets bundled: engine + bridge
sceneBundle.js GENERATED — that bundle, with Pixi, as a JS string
playground/ Vite demo: every option as a live control
scripts/ build toolingBoth hosts run the same engine. The scene itself knows nothing about where it
lives: it draws, reads the gesture it is handed, and reports through emit —
which becomes a callback in a browser and a postMessage across the WebView
bridge on React Native.
The scene cannot go through Metro, so it is bundled ahead of time. The committed bundle must match the sources:
pnpm build # scene bundle, then dist
pnpm verify:scene # fails when the committed bundle is stale
pnpm typecheck
pnpm playground # http://localhost:5173 — /index.html and /react.htmlThe playground runs on built-in placeholder artwork, so it works with no assets at hand, and most of the schema is a live control there, grouped the way this README groups it — the fastest way to see what a number does. Sliders retune the running scene instead of restarting it.
Not done yet
The slice scene is plain JS, not TypeScript: it is split into modules and is
covered by the variant interface, but typing it would make a second variant
cheaper to write.
License
MIT for the code. The sounds carry their own licences, listed in
assets/sounds/CREDITS.md.
