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

@whatpull/webjet

v2.0.18

Published

Hybrid canvas+CSS slot machine for React

Readme

Whatpull Web‑Jet Series (React)

두 가지 고성능 UI 모듈을 제공합니다.

  1. 🎰 SlotMachineHybrid — Canvas + CSS 하이브리드 슬롯머신
  2. 🏆 ReaderBoard — FLIP 재배치 애니메이션과 테마(Provider) 기반 리더보드

npm license build

패키지명이 다르면 아래 @whatpull/webjet을 실제 패키지명으로 바꿔 사용하세요.


설치

# 런타임
pnpm add styled-components
# (npm/yarn 사용 가능)

# 본 라이브러리
pnpm add @whatpull/webjet

React 18+ / Next.js 13+(App Router) 권장. TypeScript 사용 시 아래 테마 타입 설정 참고.


빠른 데모 (두 모듈 한 화면)

App.tsx 예시(요약):

import React, { useRef, useState, useEffect } from "react";
import SlotMachineHybrid, { type SlotMachineHandle, type SymbolItem } from "@whatpull/webjet";
import {
  ReaderBoard,
  ReaderBoardThemeProvider,
  ReaderBoardStyle,
  type RankCriteria,
  type UnitOption,
  type DisplayFields,
} from "@whatpull/webjet";

export default function App() {
  // ── SlotMachineHybrid
  const slotRef = useRef<SlotMachineHandle>(null);
  const symbols: SymbolItem[] = [
    { seq: 1, image: "https://picsum.photos/id/1015/800/600" },
    { seq: 2, image: "https://picsum.photos/id/1016/800/600" },
    { seq: 3, image: "https://picsum.photos/id/1025/800/600" },
  ];

  // ── ReaderBoard
  const [styleKey] = useState(ReaderBoardStyle.Basic);
  const [criteria, setCriteria] = useState<RankCriteria>("supportScore");
  const unit: UnitOption = { label: "만 캐시", scale: 10_000 };
  const display: DisplayFields = { rank: true, rankDelta: true, supportScore: true, contribution: true };

  type User = { id: number; nickname: string; support: number; contribution: number };
  const [data, setData] = useState<User[]>([
    { id: 1, nickname: "Alice", support: 541000, contribution: 20000 },
    { id: 2, nickname: "Bob",   support: 230000, contribution: 32000 },
    { id: 3, nickname: "Chan",  support: 120000, contribution: 50000 },
  ]);

  useEffect(() => {
    const t = setInterval(() => {
      setData(prev => prev.map(u => ({
        ...u,
        support: Math.max(0, u.support + Math.round((Math.random() - 0.5) * 50000)),
      })));
    }, 2000);
    return () => clearInterval(t);
  }, []);

  return (
    <div style={{ padding: 40, display: "grid", gap: 32 }}>
      {/* 1) SlotMachineHybrid */}
      <SlotMachineHybrid
        ref={slotRef}
        reels={3}
        symbols={symbols}
        size={{ w: 140, h: 220 }}
        imageFit="cover"
        reelDirections={["up", "up", "up"]}
        rampUpDuration={110}
        spinSpeed={3600}
        minSpinCycles={5}
        stopDuration={1000}
        settleOvershootPx={Math.round(220 * 0.12)}
        settleSplit={0.72}
        reelDelayStep={120}
        stopOrder="left-to-right"
        stopDelayStep={1000}
        autoPxPerFrameCap
        spinFilter="blur(0.6px)"
        edgeFade={0.12}
        reelgap="12px"
      />

      {/* 2) ReaderBoard */}
      <ReaderBoardThemeProvider styleKey={styleKey}>
        <ReaderBoard<User>
          criteria={criteria}
          unit={unit}
          display={display}
          animateOnRankChange
          data={data}
          accessors={{
            getKey: u => u.id,
            getRole: u => u.nickname,
            getSupportScore: u => u.support,
            getContribution: u => u.contribution,
          }}
        />
      </ReaderBoardThemeProvider>

      <button onClick={() => setCriteria(c => (c === "supportScore" ? "contribution" : "supportScore"))}>
        기준 토글
      </button>
    </div>
  );
}

1) 🎰 SlotMachineHybrid

특징

  • Hybrid Canvas + CSS transform: rAF 기반 부드러운 회전
  • 터보 스핀/가속: spinSpeed, rampUpDuration
  • 자연 정지(오버슈트→안착): settleOvershootPx, settleSplit, stopDuration
  • 릴 순차 정지: stopOrder, stopDelayStep
  • 시각 피로 완화: spinFilter, edgeFade
  • 릴 간격: reelgap

주요 props (요약)

export type SymbolItem = {
  seq: number;
  label?: string;
  bg?: string;
  fg?: string;
  image?: string;
  crossOrigin?: "" | "anonymous" | "use-credentials";
};

export type SlotMachineHybridProps = {
  reels?: number;
  symbols?: SymbolItem[];
  size?: { w: number; h: number };
  spinSpeed?: number;
  rampUpDuration?: number;
  minSpinCycles?: number;
  stopDuration?: number;
  settleOvershootPx?: number;
  settleSplit?: number;
  reelDelayStep?: number;
  reelDirections?: ("down" | "up")[];
  stopOrder?: "left-to-right" | "right-to-left" | "simultaneous";
  stopDelayStep?: number;
  imageFit?: "cover" | "contain" | "fill";
  autoPxPerFrameCap?: boolean;
  pxPerFrameCap?: number;
  spinFilter?: string;
  edgeFade?: number;
  reelgap?: string;
  onStop?: (stops: number[]) => void;
};

export type SlotMachineHandle = {
  spin(): void;
  spinTo(indices: number[], opts?: { stopDuration?: number; delayStep?: number }): void;
  spinToLabels(labels: string[], opts?: { stopDuration?: number; delayStep?: number }): void;
  spinToSequences(seqs: number[], opts?: { stopDuration?: number; delayStep?: number }): void;
  isSpinning(): boolean;
  stop(opts?: { stopDuration?: number }): void;
};

2) 🏆 ReaderBoard

특징

  • 제네릭 + forwardRef: <ReaderBoard<User> ...> 형태로 외부에서 T 지정
  • FLIP 재배치 애니메이션: 순위/정렬 변경 시 행이 드래그앤드롭처럼 자연 이동
  • styled‑components ThemeProvider 스킨: ReaderBoardThemeProvider + ReaderBoardStyle enum
  • 카드형 Row: 배지/타이틀/통계/Progress(또는 Lock) 레이아웃
  • 상승/하락 Δ 표시(옵션): 이전 랭크 대비 변화 아이콘/색상

사용

<ReaderBoardThemeProvider styleKey={ReaderBoardStyle.Basic}>
  <ReaderBoard<User>
    criteria="supportScore"
    unit={{ label: "만 캐시", scale: 10_000 }}
    display={{ rank: true, rankDelta: true, supportScore: true, contribution: true }}
    animateOnRankChange
    data={users}
    accessors={{
      getKey: u => u.id,
      getRole: u => u.nickname,
      getSupportScore: u => u.support,
      getContribution: u => u.contribution,
      // (옵션) getSubtitle, getProgress(0~100), getLocked 등 확장 가능
    }}
  />
</ReaderBoardThemeProvider>

타입

export type RankCriteria = "supportScore" | "contribution";
export type UnitOption   = { label: string; scale: number };
export type DisplayFields = {
  season?: boolean; broadcast?: boolean;
  rank?: boolean; rankDelta?: boolean;
  role?: boolean;
  supportScore?: boolean; contribution?: boolean;
  itemName?: boolean | string; itemCount?: boolean | string;
};

export type ReaderBoardAccessors<T> = {
  getKey: (item: T, idx: number) => React.Key;
  getSeason?: (item: T) => string | number;
  getBroadcast?: (item: T) => string | number;
  getRank?: (item: T) => number;
  getRole?: (item: T) => string;
  getSupportScore?: (item: T) => number;
  getContribution?: (item: T) => number;
  getItemName?: (item: T) => string;
  getItemCount?: (item: T) => number;
  // 카드형 보조정보(옵션)
  getSubtitle?: (item: T) => string;
  getProgress?: (item: T) => number;   // 0~100
  getLocked?: (item: T) => boolean;    // 잠금 표시
};

export type ReaderBoardProps<T> = {
  widgetUrl?: string;
  onOpenWidgetUrl?: (url: string) => void;
  onCopyWidgetUrl?: (url: string) => void;

  criteria: RankCriteria;
  unit: UnitOption;
  display: DisplayFields;
  animateOnRankChange: boolean;

  data: T[];
  accessors: ReaderBoardAccessors<T>;
};

export type ReaderBoardHandle = { stop: () => void; spin?: () => void };

테마 & Provider

export enum ReaderBoardStyle { Basic = "basic", Dark = "dark", Neon = "neon" }

export type ReaderBoardTheme = {
  name: ReaderBoardStyle;
  colors: {
    bg: string; headerBg: string; rowBg: string; rowAltBg?: string; rowHoverBg?: string; rowBorder: string;
    text: string; subtext: string; accent: string; rankBadgeBg: string; rankBadgeText: string; topRankBg?: string;
    upColor?: string; downColor?: string;
  };
  layout: {
    radius: number; gap: number; padX: number; padY: number; rowMinHeight: number; colMinWidth?: number;
    zebra?: boolean; stickyHeader?: boolean;
  };
  font: { family: string; size: number; headerSize?: number; weight: number; headerWeight?: number; mono?: string };
  shadow: { table?: string; rowHover?: string; badge?: string };
  effects: { reorderMs: number; easing: string; highlightMs: number };
  rankBadge: { show?: boolean; width: number; radius: number };
};
<ReaderBoardThemeProvider
  styleKey={ReaderBoardStyle.Basic}
  // overrideTheme={{ colors: { accent: "#ff55aa" } }}
>
  <ReaderBoard ... />
</ReaderBoardThemeProvider>

테마 타입 설정 (TypeScript)

styled-components v6 사용 시:

src/definition/styled.d.ts

import "styled-components";
import type { ReaderBoardTheme } from "../readerboard/readerboard-types";

declare module "styled-components" {
  export interface DefaultTheme extends ReaderBoardTheme {}
}

tsconfig.json

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "module": "ESNext",
    "target": "ES2020",
    "moduleResolution": "Bundler",
    "strict": true,
    "skipLibCheck": true,
    "types": ["react", "react-dom", "styled-components"],
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src", "src/**/*.d.ts"]
}

v5를 사용할 경우에만 @types/styled-components를 devDependency로 설치하고, tsconfig의 types에 추가하세요.


패키지 내보내기 (Exports)

// index.ts
export { default as SlotMachineHybrid } from "./slotmachine/SlotMachineHybrid";
export type { SlotMachineHybridProps, SymbolItem, SlotMachineHandle } from "./slotmachine/SlotMachineHybrid";

export { ReaderBoard } from "./readerboard/ReaderBoard";
export type { ReaderBoardHandle, ReaderBoardProps, ReaderBoardAccessors } from "./readerboard/ReaderBoard";

export { ReaderBoardThemeProvider, READERBOARD_PRESETS } from "./readerboard/ReaderBoardThemeProvider";
export { ReaderBoardStyle } from "./readerboard/readerboard-types";
export type { ReaderBoardTheme, RankCriteria, UnitOption, DisplayFields } from "./readerboard/readerboard-types";

트러블슈팅

  • Cannot find type definition file for '@types/styled-components'
    • v6에서는 타입이 내장되어 있으므로 tsconfig.compilerOptions.types에서 @types/styled-components를 제거하고 "styled-components"만 남기세요.
  • Property 'layout' does not exist on type 'DefaultTheme'
    • styled.d.ts 선언 병합이 빠졌거나 include 경로 밖에 있을 수 있습니다.
  • FLIP 애니메이션이 보이지 않음
    • Row의 ref 콜백이 제대로 주입됐는지, key가 안정적인지 확인하세요.
    • 정렬 기준 변경/데이터 업데이트 시 keys 배열이 실제로 바뀌는지 확인하세요.
  • Form elements must have labels
    • 읽기전용 URL input에는 <label htmlFor="..."> 연결 또는 aria-label을 추가하세요.

라이선스

ISC © whatpull