chore: import @unom/ui from @played/ui

Copy of the played-ui component library, rebranded to the @unom scope
for publishing as @unom/ui.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 14:09:25 +02:00
co-authored by Claude Opus 4.8
commit ccf93a85a0
74 changed files with 7012 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# dependencies (bun install)
node_modules
# output
out
dist
storybook-static
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+14
View File
@@ -0,0 +1,14 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(ts|tsx)"],
addons: [],
framework: {
name: "@storybook/react-vite",
options: {
builder: { viteConfigPath: ".storybook/vite.storybook.config.ts" },
},
},
};
export default config;
+24
View File
@@ -0,0 +1,24 @@
import "../src/styles/theme.css";
import { definePreview } from "@storybook/react-vite";
import { AnimationProvider } from "@/animation/provider";
import Section from "@/section";
import { SoundProvider } from "@/sound/provider";
export default definePreview({
addons: [],
decorators: [
(Story) => (
<AnimationProvider theme={{}}>
<SoundProvider theme={{}}>
<Section maxWidth={false}>
<Story />
</Section>
</SoundProvider>
</AnimationProvider>
),
],
parameters: {
controls: { matchers: { color: /(background|color)$/i, date: /Date$/ } },
layout: "fullscreen",
},
});
+13
View File
@@ -0,0 +1,13 @@
import { resolve } from "node:path";
import tailwindcss from "@tailwindcss/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
resolve: {
alias: {
"@": resolve(__dirname, "../src"),
},
},
plugins: [viteReact(), tailwindcss()],
});
+2
View File
@@ -0,0 +1,2 @@
# ui
+39
View File
@@ -0,0 +1,39 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+1623
View File
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
{
"name": "@unom/ui",
"type": "module",
"version": "0.8.16",
"description": "ui elements for unom",
"private": false,
"files": [
"dist"
],
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"storybook": "storybook dev -p 6011",
"build-storybook": "storybook build"
},
"devDependencies": {
"@biomejs/biome": "^2.4.16",
"@bosh-code/tsdown-plugin-inject-css": "^2.0.0",
"@bosh-code/tsdown-plugin-tailwindcss": "^1.0.1",
"@rollup/plugin-url": "^8.0.2",
"@storybook/react-vite": "^10.4.2",
"@tailwindcss/vite": "^4.3.0",
"@types/bun": "latest",
"@types/howler": "^2.2.13",
"@types/react": "^19.2.16",
"@vitejs/plugin-react": "^6.0.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"storybook": "^10.4.2",
"tailwindcss": "^4.3.0",
"tsdown": "^0.22.1",
"tw-animate-css": "^1.4.0",
"vite": "^8.0.16"
},
"peerDependencies": {
"@payloadcms/richtext-lexical": "^3.85.0",
"@tanstack/react-virtual": "^3.14.2",
"@unom/style": "^0.4.4",
"class-variance-authority": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^1.17.0",
"motion": "^12.40.0",
"radix-ui": "^1.4.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"typescript": "^6.0.3",
"zod": "^4.4.3"
},
"exports": {
"./accordion": "./dist/accordion.js",
"./animate-number": "./dist/animate-number.js",
"./animation": "./dist/animation/index.js",
"./button": "./dist/button.js",
"./card": "./dist/card.js",
"./card-context": "./dist/card-context.js",
"./dialog": "./dist/dialog.js",
"./elastic-slider": "./dist/elastic-slider.js",
"./form/checkbox": "./dist/form/checkbox.js",
"./form/field-container": "./dist/form/field-container.js",
"./form/form": "./dist/form/form.js",
"./form/input-number": "./dist/form/input-number.js",
"./form/input-text": "./dist/form/input-text.js",
"./form/label": "./dist/form/label.js",
"./form/select": "./dist/form/select.js",
"./form/textarea": "./dist/form/textarea.js",
"./hooks/useControllableState": "./dist/hooks/useControllableState.js",
"./lib/utils": "./dist/lib/utils.js",
"./preload-reload": "./dist/preload-reload.js",
"./sound": "./dist/sound/index.js",
"./richtext": "./dist/richtext/index.js",
"./richtext/converters": "./dist/richtext/converters/index.js",
"./richtext/converters/headings": "./dist/richtext/converters/headings.js",
"./richtext/converters/internal-link": "./dist/richtext/converters/internal-link.js",
"./section": "./dist/section/index.js",
"./section/heading": "./dist/section/heading/index.js",
"./section/section.types": "./dist/section/section.types.js",
"./styles/animations": "./dist/styles/animations.js",
"./styles/material.css": "./dist/material.css",
"./material": "./dist/material/index.js",
"./tabs": "./dist/tabs.js",
"./text": "./dist/text.js",
"./toast": "./dist/toast.js",
"./toggle-group": "./dist/toggle-group.js",
"./virtualized-grid": "./dist/virtualized-grid.js",
"./package.json": "./package.json"
},
"dependencies": {
"@tanstack/react-router": "^1.170.11",
"@tsdown/css": "^0.22.1",
"clsx": "^2.1.1",
"howler": "^2.2.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0"
},
"inlinedDependencies": {
"framer-motion": "12.38.0",
"motion-dom": "12.38.0",
"motion-utils": "12.36.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>played/workflows:renovate-config"
]
}
+60
View File
@@ -0,0 +1,60 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./accordion";
const meta: Meta<typeof Accordion> = {
title: "accordion/Accordion",
component: Accordion,
};
export default meta;
type Story = StoryObj<typeof Accordion>;
export const Single: Story = {
render: () => (
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes it uses the @unom/ui accordion animation tokens.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Can I open many at once?</AccordionTrigger>
<AccordionContent>
Switch <code>type</code> to <code>"multiple"</code>.
</AccordionContent>
</AccordionItem>
</Accordion>
),
};
export const Multiple: Story = {
render: () => (
<Accordion type="multiple" defaultValue={["a", "b"]}>
<AccordionItem value="a">
<AccordionTrigger>First</AccordionTrigger>
<AccordionContent>First content.</AccordionContent>
</AccordionItem>
<AccordionItem value="b">
<AccordionTrigger>Second</AccordionTrigger>
<AccordionContent>Second content.</AccordionContent>
</AccordionItem>
<AccordionItem value="c">
<AccordionTrigger>Third</AccordionTrigger>
<AccordionContent>Third content.</AccordionContent>
</AccordionItem>
</Accordion>
),
};
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { ChevronDownIcon } from "lucide-react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn(" last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between \
gap-4 rounded-md py-4 text-left text-sm font-medium outline-none hover:underline \
focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
+330
View File
@@ -0,0 +1,330 @@
import type { Variants } from "motion";
import {
AnimatePresence,
motion,
type Transition,
useReducedMotion,
} from "motion/react";
import { type FC, useEffect, useMemo, useRef } from "react";
import { cn } from "@/lib/utils";
type Group = "prefix" | "integer" | "fraction" | "suffix";
type Direction = "up" | "down";
type Slot = {
slotKey: string;
group: Group;
value: string;
};
export type AnimateNumberProps = {
/** The numeric value to display. When it changes, the slot whose character
* changed animates in the direction matching whether the overall value
* went up or down. */
children: number;
/** Options passed to `Intl.NumberFormat` as the second arg. Controls
* currency, units, grouping, fraction digits, compact notation, etc. */
format?: Intl.NumberFormatOptions;
/** Locale(s) passed to `Intl.NumberFormat` as the first arg. Defaults to
* the host locale. */
locales?: Intl.LocalesArgument;
/** Motion transition applied to every per-character slide + fade. */
transition?: Transition;
className?: string;
/** Extra classes applied to leading non-numeric chars (currency, signs). */
prefixClassName?: string;
/** Extra classes applied to integer-side digits and group separators. */
integerClassName?: string;
/** Extra classes applied to the decimal separator and fraction digits. */
fractionClassName?: string;
/** Extra classes applied to trailing non-numeric chars (percent, units). */
suffixClassName?: string;
};
const DEFAULT_TRANSITION: Transition = {
type: "spring",
stiffness: 320,
damping: 28,
mass: 0.6,
};
// up = value went up → digits roll DOWN (new enters from top, old exits bottom)
// down = value went down → digits roll UP (new enters from bottom, old exits top)
const SLOT_VARIANTS: Variants = {
enter: (dir: Direction) => ({
y: dir === "up" ? "-100%" : "100%",
opacity: 0,
}),
visible: { y: 0, opacity: 1 },
exit: (dir: Direction) => ({
y: dir === "up" ? "100%" : "-100%",
opacity: 0,
}),
};
// Outer wrapper variants — used when a whole slot is added (e.g. 99 → 100) or
// removed (e.g. 100 → 99). Subtle fade so the digit-count change isn't a snap.
const WRAPPER_VARIANTS: Variants = {
enter: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
};
const partition = (
value: number,
locales: Intl.LocalesArgument | undefined,
format: Intl.NumberFormatOptions | undefined,
): Slot[] => {
const parts = new Intl.NumberFormat(locales, format).formatToParts(value);
const isIntChar = (t: Intl.NumberFormatPart["type"]) =>
t === "integer" || t === "group";
const isFracChar = (t: Intl.NumberFormatPart["type"]) =>
t === "decimal" || t === "fraction";
const firstNumericIdx = parts.findIndex(
(p) => isIntChar(p.type) || isFracChar(p.type),
);
let lastNumericIdx = -1;
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (!p) continue;
if (isIntChar(p.type) || isFracChar(p.type)) {
lastNumericIdx = i;
break;
}
}
let intCharCount = 0;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (!p) continue;
if (isIntChar(p.type)) intCharCount += p.value.length;
else if (isFracChar(p.type)) break;
}
const out: Slot[] = [];
let intPosFromStart = 0;
let fracPosFromStart = 0;
let prefixSeq = 0;
let suffixSeq = 0;
parts.forEach((p, i) => {
const beforeNumeric = i < firstNumericIdx;
const afterNumeric = i > lastNumericIdx;
if (beforeNumeric) {
for (const ch of p.value) {
out.push({
slotKey: `pre-${prefixSeq++}-${p.type}`,
value: ch,
group: "prefix",
});
}
return;
}
if (afterNumeric) {
for (const ch of p.value) {
out.push({
slotKey: `suf-${suffixSeq++}-${p.type}`,
value: ch,
group: "suffix",
});
}
return;
}
if (isIntChar(p.type)) {
for (const ch of p.value) {
const posFromEnd = intCharCount - 1 - intPosFromStart;
out.push({
slotKey: `int-${posFromEnd}`,
value: ch,
group: "integer",
});
intPosFromStart += 1;
}
return;
}
if (p.type === "decimal") {
out.push({ slotKey: "decimal", value: p.value, group: "fraction" });
return;
}
if (p.type === "fraction") {
for (const ch of p.value) {
out.push({
slotKey: `frac-${fracPosFromStart}`,
value: ch,
group: "fraction",
});
fracPosFromStart += 1;
}
return;
}
});
return out;
};
const groupClassName = (
group: Group,
prefix?: string,
integer?: string,
fraction?: string,
suffix?: string,
) => {
switch (group) {
case "prefix":
return prefix;
case "integer":
return integer;
case "fraction":
return fraction;
case "suffix":
return suffix;
}
};
interface SlotViewProps {
slot: Slot;
direction: Direction;
transition: Transition;
groupClass?: string;
}
const SlotView: FC<SlotViewProps> = ({
slot,
direction,
transition,
groupClass,
}) => {
// Bump a tick on every value change so each transition uses a fresh React
// key. Without this, a value that re-appears before the prior exit finishes
// would reuse the still-exiting motion node (visual glitch on fast sweeps).
const tickRef = useRef(0);
const prevValueRef = useRef<string | null>(null);
if (prevValueRef.current !== slot.value) {
tickRef.current += 1;
prevValueRef.current = slot.value;
}
return (
// No `layout` prop here on purpose: when the parent (e.g. a slider's
// popover) is transforming via motion values, motion's layout system
// would re-measure each slot's viewport rect every frame and spring
// toward the new position, which lags the parent transform and looks
// like the digits drift in the slide direction. Slot adds/removes
// (e.g. 99 → 100) snap instead of animating position — acceptable.
<motion.span
aria-hidden="true"
variants={WRAPPER_VARIANTS}
initial="enter"
animate="visible"
exit="exit"
transition={transition}
className="relative inline-block overflow-hidden align-baseline leading-[1em]"
>
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.span
key={tickRef.current}
custom={direction}
variants={SLOT_VARIANTS}
initial="enter"
animate="visible"
exit="exit"
transition={transition}
className={cn("inline-block", groupClass)}
>
{slot.value === " " ? " " : slot.value}
</motion.span>
</AnimatePresence>
</motion.span>
);
};
/**
* Animates between numeric values by sliding each character into place. Uses
* `Intl.NumberFormat` for locale-aware formatting (currency, units, grouping,
* compact notation, etc.).
*
* Stable digits stay put across renders; only the slots whose character
* actually changed animate. When the overall value increases, digits roll
* down (new enters from above); when it decreases, digits roll up.
*
* For composite displays like `mm:ss`, compose multiple `AnimateNumber`
* instances with literal separators between them.
*/
export const AnimateNumber: FC<AnimateNumberProps> = ({
children,
format,
locales,
transition = DEFAULT_TRANSITION,
className,
prefixClassName,
integerClassName,
fractionClassName,
suffixClassName,
}) => {
const shouldReduceMotion = useReducedMotion();
const slots = useMemo(
() => partition(children, locales, format),
[children, locales, format],
);
const fullFormatted = useMemo(
() => new Intl.NumberFormat(locales, format).format(children),
[children, locales, format],
);
// Compare to previous render's value to derive scroll direction. Updated
// in an effect so Strict Mode's double-render doesn't desync the ref.
const prevValueRef = useRef(children);
const direction: Direction =
children >= prevValueRef.current ? "up" : "down";
useEffect(() => {
prevValueRef.current = children;
}, [children]);
if (shouldReduceMotion) {
return (
<span
className={cn("inline-flex items-baseline tabular-nums", className)}
>
{fullFormatted}
</span>
);
}
return (
<span
className={cn(
"inline-flex items-baseline tabular-nums relative",
className,
)}
>
<span className="sr-only">{fullFormatted}</span>
<AnimatePresence initial={false} mode="popLayout">
{slots.map((slot) => (
<SlotView
key={slot.slotKey}
slot={slot}
direction={direction}
transition={transition}
groupClass={groupClassName(
slot.group,
prefixClassName,
integerClassName,
fractionClassName,
suffixClassName,
)}
/>
))}
</AnimatePresence>
</span>
);
};
+64
View File
@@ -0,0 +1,64 @@
import { ease } from "@unom/style";
import { stagger } from "motion/react";
import type { AnimationTheme } from "./types";
export const defaultAnimationTheme: AnimationTheme = {
button: {
variants: {
enter: { scale: 1, opacity: 1 },
from: { scale: 0, opacity: 0 },
exit: { scale: 0.8, opacity: 0 },
},
whileHover: { scale: 1.05, opacity: 1 },
whileTap: { scale: 0.95, opacity: 1 },
responsiveHover: { minScale: 1.01, maxScale: 1.05, referenceWidth: 80 },
},
card: {
variants: {
enter: { scale: 1, opacity: 1 },
from: { scale: 0.8, opacity: 0 },
},
transition: ease.quint(0.8).out,
},
checkbox: {
variants: {
enter: { scale: 1, opacity: 1 },
from: { scale: 0, opacity: 0 },
},
whileHover: { scale: 1.1 },
whileTap: { scale: 0.9 },
transition: ease.quint(0.6).out,
},
text: {
variants: {
enter: { y: 0, opacity: 1 },
from: { y: 10, opacity: 0 },
},
transition: ease.quint(0.8).out,
},
fieldContainer: {
variants: {
enter: { opacity: 1, y: 0 },
from: { opacity: 0, y: -20 },
},
transition: ease.quint(0.9).out,
},
headingIcon: {
variants: {
enter: { y: 20, scale: 1, x: -30, rotate: -15 },
from: { y: 100, scale: 0, x: 0, rotate: 0 },
},
transition: ease.quint(2.5).out,
},
section: {
transition: { delayChildren: stagger(0.1) },
variants: {
container: {
enter: {},
from: {},
exit: {},
},
},
},
dialog: {},
};
+16
View File
@@ -0,0 +1,16 @@
export { defaultAnimationTheme } from "./defaults";
export { mergeTheme } from "./merge";
export {
AnimationProvider,
type AnimationProviderProps,
useAnimation,
useAnimationTheme,
} from "./provider";
export type {
AnimationTheme,
AnimationToken,
PartialAnimationTheme,
ResponsiveHoverConfig,
SectionAnimationToken,
} from "./types";
export { useResponsiveHover } from "./use-responsive-hover";
+25
View File
@@ -0,0 +1,25 @@
import type { AnimationTheme, PartialAnimationTheme } from "./types";
function setMerged<K extends keyof AnimationTheme>(
out: AnimationTheme,
key: K,
base: AnimationTheme,
partial: Partial<AnimationTheme[K]>,
) {
// Shallow per-token merge: token fields replace wholesale.
// Deep-merging variants/transitions would create surprising hybrids.
out[key] = { ...base[key], ...partial };
}
export function mergeTheme(
base: AnimationTheme,
override: PartialAnimationTheme,
): AnimationTheme {
const out = { ...base } as AnimationTheme;
for (const key of Object.keys(override) as (keyof AnimationTheme)[]) {
const partial = override[key];
if (!partial) continue;
setMerged(out, key, base, partial);
}
return out;
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import {
createContext,
type ReactNode,
useContext,
useMemo,
} from "react";
import { defaultAnimationTheme } from "./defaults";
import { mergeTheme } from "./merge";
import type { AnimationTheme, PartialAnimationTheme } from "./types";
const AnimationContext = createContext<AnimationTheme>(defaultAnimationTheme);
export type AnimationProviderProps = {
theme: PartialAnimationTheme;
children: ReactNode;
};
export function AnimationProvider({
theme,
children,
}: AnimationProviderProps) {
const parent = useContext(AnimationContext);
const merged = useMemo(() => mergeTheme(parent, theme), [parent, theme]);
return (
<AnimationContext.Provider value={merged}>
{children}
</AnimationContext.Provider>
);
}
export function useAnimation<K extends keyof AnimationTheme>(
key: K,
): AnimationTheme[K] {
return useContext(AnimationContext)[key];
}
export function useAnimationTheme(): AnimationTheme {
return useContext(AnimationContext);
}
+39
View File
@@ -0,0 +1,39 @@
import type { TargetAndTransition, Transition, Variants } from "motion/react";
export type ResponsiveHoverConfig = {
/** Smallest scale delta, applied at large widths. e.g. 1.01 means at most +1%. */
minScale?: number;
/** Largest scale delta, applied at the reference width. e.g. 1.05 means at most +5%. */
maxScale?: number;
/** Width (px) at which `maxScale` applies. Above this, scale falls off. */
referenceWidth?: number;
};
export type AnimationToken = {
variants?: Variants;
transition?: Transition;
whileHover?: TargetAndTransition;
whileTap?: TargetAndTransition;
/** If present, `whileHover.scale` is replaced with a width-derived value at runtime. */
responsiveHover?: ResponsiveHoverConfig;
};
export type SectionAnimationToken = {
transition?: Transition;
variants?: { container: Variants };
};
export type AnimationTheme = {
button: AnimationToken;
card: AnimationToken;
checkbox: AnimationToken;
text: AnimationToken;
fieldContainer: AnimationToken;
headingIcon: AnimationToken;
section: SectionAnimationToken;
dialog: AnimationToken;
};
export type PartialAnimationTheme = {
[K in keyof AnimationTheme]?: Partial<AnimationTheme[K]>;
};
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useLayoutEffect, useRef, useState } from "react";
import type { ResponsiveHoverConfig } from "./types";
export function useResponsiveHover<T extends HTMLElement = HTMLElement>(
config: ResponsiveHoverConfig | undefined,
) {
const ref = useRef<T | null>(null);
const [scale, setScale] = useState<number>(config?.maxScale ?? 1.05);
useLayoutEffect(() => {
const node = ref.current;
if (!config || !node || typeof ResizeObserver === "undefined") return;
const {
minScale = 1.01,
maxScale = 1.05,
referenceWidth = 80,
} = config;
const maxDelta = maxScale - 1;
const minDelta = minScale - 1;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const width = entry.contentRect.width || referenceWidth;
const delta = (referenceWidth / width) * maxDelta;
const clamped = Math.min(Math.max(delta, minDelta), maxDelta);
setScale(1 + clamped);
});
observer.observe(node);
return () => observer.disconnect();
}, [config?.minScale, config?.maxScale, config?.referenceWidth]);
return { ref, scale };
}
Binary file not shown.
+68
View File
@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Heart } from "lucide-react";
import { AnimatedButton, Button } from "./button";
const meta: Meta<typeof Button> = {
title: "button/Button",
component: Button,
argTypes: {
variant: {
control: "select",
options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
},
size: {
control: "select",
options: ["default", "sm", "lg", "icon"],
},
disabled: { control: "boolean" },
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Default: Story = { args: { children: "Click me" } };
export const Destructive: Story = {
args: { children: "Delete", variant: "destructive" },
};
export const Outline: Story = {
args: { children: "Outline", variant: "outline" },
};
export const Secondary: Story = {
args: { children: "Secondary", variant: "secondary" },
};
export const Ghost: Story = { args: { children: "Ghost", variant: "ghost" } };
export const Link: Story = { args: { children: "Link", variant: "link" } };
export const WithIcon: Story = {
args: {
children: (
<>
<Heart /> Favorite
</>
),
},
};
export const Sizes: Story = {
render: () => (
<div className="flex items-center gap-2">
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon" aria-label="Heart">
<Heart />
</Button>
</div>
),
};
export const Animated: StoryObj<typeof AnimatedButton> = {
render: (args) => <AnimatedButton {...args}>Animated</AnimatedButton>,
};
+206
View File
@@ -0,0 +1,206 @@
import { cva, type VariantProps } from "class-variance-authority";
import { type MotionProps, motion } from "motion/react";
import { Slot } from "radix-ui";
import type * as React from "react";
import type { ComponentProps, CSSProperties, FC } from "react";
import { useAnimation } from "@/animation/provider";
import { useResponsiveHover } from "@/animation/use-responsive-hover";
import { cn } from "@/lib/utils";
import { useMaterial } from "@/material/provider";
import type { MaterialPreset } from "@/material/types";
import { isWebkit, useSpecular } from "@/material/use-specular";
import { useSound } from "@/sound/provider";
const buttonVariants = cva(
"inline-flex items-center no-underline justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-color disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-neutral dark:text-main dark:text-neutral shadow-xs hover:bg-primary/90",
destructive:
"bg-error text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent/40 hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-primary/25 text-secondary-foreground shadow-xs hover:bg-primary/50 ",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
success:
"bg-success text-main dark:text-neutral shadow-xs hover:bg-success/90",
accent:
"bg-accent text-main dark:text-neutral shadow-xs hover:bg-accent/90",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 font-medium text-md px-6 has-[>svg]:px-4",
input: "h-input-height px-4 py-2 has-[>svg]:px-3",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
/**
* Default variant → material preset mapping. `solid` = full glossy fill,
* `neutral` = subtle gloss that keeps the border/ring, `none` = no material.
* Override per-game via the MaterialProvider token (`variants`/`preset`).
*/
const BUTTON_PRESETS: Record<string, MaterialPreset> = {
default: "solid",
destructive: "solid",
secondary: "neutral",
outline: "neutral",
ghost: "none",
link: "none",
success: "solid",
accent: "solid",
};
/** Solid-preset variants whose `--mat-base` isn't `--primary`. */
const SOLID_MAT_BASE: Record<string, string> = {
destructive: "var(--error)",
success: "var(--success)",
accent: "var(--accent)",
};
function Button({
className,
variant,
size,
onClick,
onMouseEnter,
onMouseLeave,
onPointerMove,
onPointerLeave,
asChild = false,
children,
style,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
const { play: playClick } = useSound("click");
const { play: playHover } = useSound("hover");
const material = useMaterial("button");
const v = variant ?? "default";
const preset: MaterialPreset =
material.preset ?? material.variants?.[v] ?? BUTTON_PRESETS[v] ?? "solid";
// Slot (asChild) expects a single child, so material (which adds the fx layer)
// only applies to the real <button>.
const active = !!material.enabled && !asChild && preset !== "none";
// Don't pointer-track on WebKit: moving `.material-spot` inside the blurred
// fx layer forces Safari to re-rasterise the blur on the CPU every mouse move
// (a big hover-perf hit) and also trips the live-blur repaint artifact. Safari
// gets a static spot — same as cards. Chromium/Firefox still track.
const track = active && !isWebkit() && (material.trackPointer ?? true);
const spec = useSpecular(track);
const matBase =
preset === "neutral"
? "var(--neutral)"
: (SOLID_MAT_BASE[v] ?? "var(--primary)");
return (
<Comp
data-slot="button"
data-material={active ? preset : undefined}
className={cn(
buttonVariants({ variant, size }),
active && "material relative",
active && preset === "neutral" && "text-main dark:text-main",
// Consumer className LAST so its utilities win conflicts (e.g. a
// `position` override is not clobbered by the material's `relative`).
className,
)}
style={
active
? ({
"--mat-base": matBase,
"--mat-radius": "var(--radius-button)",
...style,
} as CSSProperties)
: style
}
onClick={(e) => {
onClick?.(e);
playClick();
}}
onMouseEnter={(e) => {
playHover();
onMouseEnter?.(e);
}}
onPointerMove={
track
? (e) => {
onPointerMove?.(e);
spec.onPointerMove(e);
}
: onPointerMove
}
onPointerLeave={
track
? (e) => {
onPointerLeave?.(e);
spec.onPointerLeave(e);
}
: onPointerLeave
}
{...props}
>
{active ? (
<>
<span className="material-fx" aria-hidden="true">
<i className="material-sheen" />
<i className="material-spot" />
</span>
{children}
</>
) : (
children
)}
</Comp>
);
}
const MButton = motion.create(Button);
const AnimatedButton: FC<ComponentProps<typeof Button> & MotionProps> = ({
variants,
transition,
whileHover,
whileTap,
...props
}) => {
const token = useAnimation("button");
const { ref, scale } = useResponsiveHover<HTMLButtonElement>(
token.responsiveHover,
);
const resolvedHover =
whileHover ??
(token.responsiveHover ? { ...token.whileHover, scale } : token.whileHover);
return (
<MButton
variants={variants ?? token.variants}
transition={transition ?? token.transition}
whileHover={resolvedHover}
whileTap={whileTap ?? token.whileTap}
{...(token.responsiveHover && !props.ref ? { ref } : {})}
{...props}
/>
);
};
export { AnimatedButton, Button, buttonVariants };
+25
View File
@@ -0,0 +1,25 @@
import { createContext, useContext } from "react";
const CardDepthContext = createContext(0);
export function useCardDepth() {
return useContext(CardDepthContext);
}
/** The depth-aware card border-radius for the CURRENT nesting level. Cards use
* this for themselves; non-card elements that sit flush inside a card (media,
* dropzones, previews) can call it to get the radius a nested card would have —
* i.e. one level below the containing card, so they flush like a nested card. */
export function useCardRadius(): string {
const depth = useCardDepth();
return `max(var(--radius-card-min, 0rem), calc(var(--radius-card) - ${depth} * var(--spacing-padding-card)))`;
}
export function CardDepthProvider({ children }: { children: React.ReactNode }) {
const depth = useCardDepth();
return (
<CardDepthContext.Provider value={depth + 1}>
{children}
</CardDepthContext.Provider>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import Card from "./card";
const meta: Meta<typeof Card> = {
title: "card/Card",
component: Card,
argTypes: {
padding: { control: "boolean" },
interactive: { control: "boolean" },
muted: { control: "boolean" },
colorVariant: { control: "select", options: ["default", "accent"] },
},
};
export default meta;
type Story = StoryObj<typeof Card>;
export const Default: Story = {
args: {
children: (
<>
<h3>Card title</h3>
<p>Some content inside a card.</p>
</>
),
},
};
export const Interactive: Story = {
args: {
interactive: true,
children: (
<>
<h3>Hover me</h3>
<p>This card highlights on hover.</p>
</>
),
},
};
export const Accent: Story = {
args: {
colorVariant: "accent",
children: (
<>
<h3>Accent card</h3>
<p>Uses the neutral-accent background.</p>
</>
),
},
};
export const Muted: Story = {
args: {
muted: true,
children: (
<>
<h3>Muted ring</h3>
<p>Lower-emphasis variant.</p>
</>
),
},
};
export const Nested: Story = {
render: () => (
<Card>
<h3>Outer</h3>
<Card>
<h4>Inner</h4>
<p>Border radius shrinks at deeper nesting.</p>
<Card>
<h5>Inner-inner</h5>
</Card>
</Card>
</Card>
),
};
+171
View File
@@ -0,0 +1,171 @@
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { type HTMLMotionProps, motion } from "motion/react";
import { Slot } from "radix-ui";
import {
type CSSProperties,
forwardRef,
type HTMLAttributes,
type ReactNode,
} from "react";
import { useAnimation } from "@/animation/provider";
import { useResponsiveHover } from "@/animation/use-responsive-hover";
import { cn } from "@/lib/utils";
import { useMaterial } from "@/material/provider";
import { CardDepthProvider, useCardRadius } from "./card-context";
const cardVariants = cva(
`flex flex-col gap-2 ring-2 rounded-card
transition-colors grow justify-start
`,
{
variants: {
padding: {
true: "p-padding-card",
false: "",
},
interactive: {
false: "",
true: "hover:bg-accent/30 cursor-pointer",
},
colorVariant: {
default: "bg-neutral",
accent: "bg-neutral-accent",
},
muted: {
false: "ring-accent",
true: "ring-accent/50",
},
},
defaultVariants: {
interactive: false,
padding: true,
muted: false,
colorVariant: "default",
},
},
);
type CommonCardProps = {
asChild?: boolean;
className?: string;
children: ReactNode;
style?: HTMLAttributes<unknown>["style"];
} & VariantProps<typeof cardVariants>;
const Card = forwardRef<HTMLDivElement, CommonCardProps>(
(
{
asChild,
className,
interactive,
colorVariant,
padding,
style,
children,
...props
},
ref,
) => {
const material = useMaterial("card");
const Comp = asChild ? Slot.Root : "div";
// Inner-most cards "flush" with their parent by subtracting the card
// padding per nesting level from the outer radius (see useCardRadius);
// `--radius-card-min` stops it collapsing to zero past 23 levels.
const radius = useCardRadius();
// Slot (asChild) expects a single child; the fx layer would break it, so
// material only applies to the real <div>. Cards use a STATIC specular
// (no pointer tracking) — calmer on large surfaces and sidesteps the
// WebKit live-blur repaint bug entirely.
const active = !!material.enabled && !asChild;
return (
<CardDepthProvider>
<Comp
ref={ref}
data-material={active ? "neutral" : undefined}
style={
{
borderRadius: radius,
// Expose the card's depth-aware radius ALWAYS (not only when the
// material is on) so flush children — media, dropzones — can match
// it via var(--mat-radius). Without this, games that don't mount a
// MaterialProvider fall back to the full --radius-card and overhang.
"--mat-radius": radius,
...(active
? {
"--mat-base":
colorVariant === "accent"
? "var(--neutral-accent)"
: "var(--neutral)",
}
: null),
...style,
} as CSSProperties
}
className={cn(
cardVariants({ interactive, padding, colorVariant }),
active &&
"material material-card relative text-main dark:text-main",
// Consumer className LAST so its utilities win conflicts — notably
// `position` (e.g. a `sticky` card must keep sticky, not be
// overridden by the material's `relative`; sticky still establishes
// the containing block the fx layer needs).
className,
)}
{...props}
>
{active ? (
<>
<span className="material-fx" aria-hidden="true">
<i className="material-sheen" />
<i className="material-spot" />
</span>
{children}
</>
) : (
children
)}
</Comp>
</CardDepthProvider>
);
},
);
Card.displayName = "Card";
const MCard = motion.create(Card);
export const AnimatedCard: React.FC<
CommonCardProps & HTMLMotionProps<"div">
> = ({ variants, transition, whileHover, whileTap, ...props }) => {
const token = useAnimation("card");
const { ref, scale } = useResponsiveHover<HTMLDivElement>(
token.responsiveHover,
);
const resolvedHover =
whileHover ??
(token.responsiveHover ? { ...token.whileHover, scale } : token.whileHover);
return (
<MCard
variants={variants ?? token.variants}
// Preserve existing prop-merge semantics: prop transitions
// spread on top of the token transition.
transition={{ ...token.transition, ...transition }}
whileHover={resolvedHover}
whileTap={whileTap ?? token.whileTap}
{...(token.responsiveHover && !props.ref ? { ref } : {})}
{...props}
/>
);
};
export default Card;
export { cardVariants };
+198
View File
@@ -0,0 +1,198 @@
"use client";
import { type HTMLMotionProps, motion } from "motion/react";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { useAnimation } from "@/animation/provider";
import { Button } from "@/button";
import { cn } from "@/lib/utils";
import { useMaterial } from "@/material/provider";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-100 bg-black/50 backdrop-blur-lg",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
style,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
const mat = useMaterial("dialog");
const active = !!mat.enabled;
return (
<DialogPrimitive.Content
data-slot="dialog-content"
data-material={active ? "neutral" : undefined}
style={
active
? ({
"--mat-base": "var(--neutral)",
"--mat-radius": "var(--radius-card)",
...style,
} as React.CSSProperties)
: style
}
className={cn(
"rounded-card text-main",
// Always paint an opaque panel; the material layers on top when enabled.
// Without the non-material fallback the modal is transparent in games
// that don't mount a MaterialProvider.
active
? "material relative"
: "border border-main/15 bg-neutral shadow-xl",
className,
)}
{...props}
>
{active && (
<span className="material-fx" aria-hidden="true">
<i className="material-sheen" />
<i className="material-spot" />
</span>
)}
{children}
</DialogPrimitive.Content>
);
}
const MDialogContent = motion.create(DialogContent);
type AnimatedDialogContentProps = React.ComponentProps<typeof DialogContent> &
HTMLMotionProps<"div">;
function AnimatedDialogContent({
variants,
transition,
whileHover,
whileTap,
...props
}: AnimatedDialogContentProps) {
const token = useAnimation("dialog");
return (
<MDialogContent
variants={variants ?? token.variants}
transition={transition ?? token.transition}
whileHover={whileHover ?? token.whileHover}
whileTap={whileTap ?? token.whileTap}
{...props}
/>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
AnimatedDialogContent,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+584
View File
@@ -0,0 +1,584 @@
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { AnimateNumber } from "@/animate-number";
import { useControllableState } from "@/hooks/useControllableState";
import { cn } from "@/lib/utils";
import { useMaterial } from "@/material/provider";
// Drag detection & rubber band
const CLICK_THRESHOLD = 3;
const DEAD_ZONE = 32;
const MAX_CURSOR_RANGE = 200;
const MAX_STRETCH = 8;
// Layout offsets used by the "handle dodges label/value" calculation.
const HANDLE_BUFFER = 8;
const LABEL_OFFSET = 12 + 4;
const VALUE_OFFSET = 12 - 8;
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
function decimalsForStep(step: number): number {
const s = step.toString();
const dot = s.indexOf(".");
return dot === -1 ? 0 : s.length - dot - 1;
}
function roundValue(val: number, step: number): number {
const raw = Math.round(val / step) * step;
return parseFloat(raw.toFixed(decimalsForStep(step)));
}
// Magnetic snap to the nearest decile when within 3.125% of it.
function snapToDecile(rawValue: number, min: number, max: number): number {
const normalized = (rawValue - min) / (max - min);
const nearest = Math.round(normalized * 10) / 10;
if (Math.abs(normalized - nearest) <= 0.03125) {
return min + nearest * (max - min);
}
return rawValue;
}
export type ElasticSliderProps = {
/** Label shown inside the track. */
label: string;
/** Controlled value. Use together with `onValueChange` */
value?: number;
/** Initial value for uncontrolled mode. Falls back to `min` */
defaultValue?: number;
/** Called with the new value on drag, click, or key press. */
onValueChange?: (value: number) => void;
/**
* Minimum value.
* @defaultValue 0 */
min?: number;
/**
* Maximum value.
* @defaultValue 1 */
max?: number;
/**
* Smallest increment.
* @defaultValue 0.01 */
step?: number;
/** Format the displayed value. Defaults to `value.toFixed(...)` based on `step` */
formatValue?: (value: number) => string;
className?: string;
/** Accessible name. Falls back to `label` */
"aria-label"?: string;
};
export function ElasticSlider({
label,
value: valueProp,
defaultValue,
onValueChange,
min = 0,
max = 1,
step = 0.01,
formatValue,
className,
"aria-label": ariaLabel,
}: ElasticSliderProps) {
const mat = useMaterial("input");
const [value = min, setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue ?? min,
onChange: onValueChange,
});
const shouldReduceMotion = useReducedMotion();
const wrapperRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLSpanElement>(null);
const valueRef = useRef<HTMLSpanElement>(null);
const [isInteracting, setIsInteracting] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
/** Ring only for Tab focus or keyboard value nudges, not pointer press/drag. */
const [keyboardFocusRing, setKeyboardFocusRing] = useState(false);
// Pointer session state — mutable, does not trigger re-renders.
const pointerDownPos = useRef<{ x: number; y: number } | null>(null);
const pendingPointerFocusRef = useRef(false);
const isClickRef = useRef(true);
const animRef = useRef<ReturnType<typeof animate> | null>(null);
const wrapperRectRef = useRef<DOMRect | null>(null);
const scaleRef = useRef(1);
const percentage = ((value - min) / (max - min)) * 100;
const isActive = isInteracting || isHovered;
const decimals = decimalsForStep(step);
const displayValue = formatValue
? formatValue(value)
: value.toFixed(decimals);
const animatedFormat = useMemo<Intl.NumberFormatOptions>(
() => ({
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
useGrouping: false,
}),
[decimals],
);
// Fill + handle driven by a single motion value for imperative updates.
const fillPercent = useMotionValue(percentage);
const fillWidth = useTransform(fillPercent, (pct) => `${pct}%`);
const handleLeft = useTransform(
fillPercent,
(pct) => `max(4px, calc(${pct}% - 8px))`,
);
// Rubber band: widens the track and pulls it left when dragged past bounds.
const rubberStretch = useMotionValue(0);
const rubberWidth = useTransform(
rubberStretch,
(s) => `calc(100% + ${Math.abs(s)}px)`,
);
const rubberX = useTransform(rubberStretch, (s) => (s < 0 ? s : 0));
// Sync from props when not interacting and no spring is in flight.
useEffect(() => {
if (!isInteracting && !animRef.current) {
fillPercent.jump(percentage);
}
}, [percentage, isInteracting, fillPercent]);
const positionToValue = useCallback(
(clientX: number) => {
const rect = wrapperRectRef.current;
if (!rect) return min;
const sceneX = (clientX - rect.left) / scaleRef.current;
const nativeWidth = wrapperRef.current?.offsetWidth ?? rect.width;
const percent = clamp(sceneX / nativeWidth, 0, 1);
return clamp(min + percent * (max - min), min, max);
},
[min, max],
);
const percentFromValue = useCallback(
(v: number) => ((v - min) / (max - min)) * 100,
[min, max],
);
// Animate fill to a target percent, or jump instantly when the user prefers
// reduced motion. Position still updates — only the spring is skipped.
const animateFillTo = useCallback(
(targetPercent: number) => {
animRef.current?.stop();
if (shouldReduceMotion) {
fillPercent.jump(targetPercent);
animRef.current = null;
return;
}
animRef.current = animate(fillPercent, targetPercent, {
type: "spring",
stiffness: 300,
damping: 25,
mass: 0.8,
onComplete: () => {
animRef.current = null;
},
});
},
[fillPercent, shouldReduceMotion],
);
const computeRubberStretch = useCallback((clientX: number, sign: number) => {
const rect = wrapperRectRef.current;
if (!rect) return 0;
const distancePast = sign < 0 ? rect.left - clientX : clientX - rect.right;
const overflow = Math.max(0, distancePast - DEAD_ZONE);
return (
sign * MAX_STRETCH * Math.sqrt(Math.min(overflow / MAX_CURSOR_RANGE, 1))
);
}, []);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
pointerDownPos.current = { x: e.clientX, y: e.clientY };
isClickRef.current = true;
setIsInteracting(true);
pendingPointerFocusRef.current = true;
setKeyboardFocusRing(false);
// Pointer interactions should move focus to the slider so subsequent
// keyboard input is received and focus styles match the active state.
trackRef.current?.focus({ preventScroll: true });
requestAnimationFrame(() => {
pendingPointerFocusRef.current = false;
});
// Snapshot the wrapper rect so later math is immune to layout shifts.
const wrapper = wrapperRef.current;
if (wrapper) {
const rect = wrapper.getBoundingClientRect();
wrapperRectRef.current = rect;
scaleRef.current = rect.width / wrapper.offsetWidth;
}
}, []);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isInteracting || !pointerDownPos.current) return;
const dx = e.clientX - pointerDownPos.current.x;
const dy = e.clientY - pointerDownPos.current.y;
if (isClickRef.current && Math.hypot(dx, dy) > CLICK_THRESHOLD) {
isClickRef.current = false;
setIsDragging(true);
}
if (isClickRef.current) return;
const rect = wrapperRectRef.current;
if (rect && !shouldReduceMotion) {
if (e.clientX < rect.left) {
rubberStretch.jump(computeRubberStretch(e.clientX, -1));
} else if (e.clientX > rect.right) {
rubberStretch.jump(computeRubberStretch(e.clientX, 1));
} else {
rubberStretch.jump(0);
}
}
const newValue = positionToValue(e.clientX);
animRef.current?.stop();
animRef.current = null;
fillPercent.jump(percentFromValue(newValue));
setValue(roundValue(newValue, step));
},
[
isInteracting,
positionToValue,
percentFromValue,
setValue,
step,
fillPercent,
rubberStretch,
computeRubberStretch,
shouldReduceMotion,
],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
if (!isInteracting) return;
if (isClickRef.current) {
// Coarse sliders (≤10 positions) snap to the nearest step;
// continuous sliders keep the decile-magnetic behavior.
const rawValue = positionToValue(e.clientX);
const discreteSteps = (max - min) / step;
const snapped =
discreteSteps <= 10
? clamp(min + Math.round((rawValue - min) / step) * step, min, max)
: snapToDecile(rawValue, min, max);
animateFillTo(percentFromValue(snapped));
setValue(roundValue(snapped, step));
}
if (!shouldReduceMotion && rubberStretch.get() !== 0) {
animate(rubberStretch, 0, {
type: "spring",
visualDuration: 0.35,
bounce: 0.15,
});
}
setIsInteracting(false);
setIsDragging(false);
pointerDownPos.current = null;
},
[
isInteracting,
positionToValue,
percentFromValue,
setValue,
min,
max,
step,
animateFillTo,
rubberStretch,
shouldReduceMotion,
],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Shift + Arrow is a Figma-style fast nudge: jumps by 10x the step,
// independent of the WAI-ARIA Page step (which scales with range).
const arrowStep = e.shiftKey ? step * 10 : step;
let next: number | null = null;
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
next = value + arrowStep;
break;
case "ArrowLeft":
case "ArrowDown":
next = value - arrowStep;
break;
case "Home":
next = min;
break;
case "End":
next = max;
break;
default:
return;
}
e.preventDefault();
setKeyboardFocusRing(true);
const snapped = roundValue(clamp(next, min, max), step);
animateFillTo(percentFromValue(snapped));
setValue(snapped);
},
[value, min, max, step, animateFillTo, percentFromValue, setValue],
);
const handleTrackFocus = useCallback(() => {
if (!pendingPointerFocusRef.current) {
setKeyboardFocusRing(true);
}
}, []);
const handleTrackBlur = useCallback(() => {
setKeyboardFocusRing(false);
}, []);
// Measure label + value to derive "dodge" thresholds so the handle fades
// when it would overlap either text.
const [dodge, setDodge] = useState({ left: 38, right: 72 });
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useLayoutEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const measure = () => {
const trackWidth = wrapper.offsetWidth;
if (trackWidth <= 0) return;
const labelEl = labelRef.current;
const valueEl = valueRef.current;
const left = labelEl
? ((LABEL_OFFSET + labelEl.offsetWidth + HANDLE_BUFFER) / trackWidth) *
100
: 38;
const right = valueEl
? ((trackWidth - VALUE_OFFSET - valueEl.offsetWidth - HANDLE_BUFFER) /
trackWidth) *
100
: 72;
setDodge((prev) => {
return prev.left === left && prev.right === right
? prev
: { left, right };
});
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(wrapper);
if (labelRef.current) observer.observe(labelRef.current);
if (valueRef.current) observer.observe(valueRef.current);
return () => observer.disconnect();
}, [label, displayValue]);
const valueDodge = percentage < dodge.left || percentage > dodge.right;
const handleOpacity = !isActive
? 0
: valueDodge
? 0.1
: isDragging
? 0.8
: 0.5;
const discreteSteps = (max - min) / step;
const hashMarkCount = discreteSteps <= 10 ? discreteSteps - 1 : 9;
const hashMarkPct = (i: number) => {
return discreteSteps <= 10
? (((i + 1) * step) / (max - min)) * 100
: (i + 1) * 10;
};
return (
<div
ref={wrapperRef}
data-slot="elastic-slider"
className={cn(
"[--elastic-slider-height:--spacing(12)] [--elastic-slider-radius:var(--radius-lg)]",
"[--elastic-slider-bg:var(--neutral)]",
"[--elastic-slider-fill:var(--main)]/10",
"[--elastic-slider-fill-active:var(--main)]/20",
"[--elastic-slider-hash:var(--main)]/30",
"[--elastic-slider-handle:var(--main)]",
"[--elastic-slider-label:var(--main)]/70",
"[--elastic-slider-focus:var(--main)]",
"relative h-(--elastic-slider-height)",
mat.enabled &&
"[--elastic-slider-bg:color-mix(in_oklch,var(--neutral),black_4%)] [--elastic-slider-fill:var(--primary)]/35 [--elastic-slider-fill-active:var(--primary)]/55 [--elastic-slider-handle:var(--neutral)] [--elastic-slider-hash:var(--primary)]/40",
className,
)}
>
<motion.div
ref={trackRef}
role="slider"
tabIndex={0}
data-slot="elastic-slider-track"
data-active={isActive}
data-focus-visible={keyboardFocusRing}
aria-label={ariaLabel ?? label}
aria-orientation="horizontal"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={displayValue}
className={cn(
"group/elastic-slider absolute inset-0 cursor-pointer touch-none overflow-hidden rounded-(--elastic-slider-radius) bg-(--elastic-slider-bg) outline-none select-none border-main border-solid border-1",
"data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-(--elastic-slider-focus)/50 data-[focus-visible=true]:ring-offset-1 data-[focus-visible=true]:ring-offset-neutral",
mat.enabled && "material-slider-track",
)}
style={{ width: rubberWidth, x: rubberX }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onFocus={handleTrackFocus}
onBlur={handleTrackBlur}
onKeyDown={handleKeyDown}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
data-slot="elastic-slider-hash-marks"
aria-hidden="true"
className="pointer-events-none absolute inset-0"
>
{Array.from({ length: hashMarkCount }, (_, i) => (
<div
key={i.toString()}
className={cn(
"absolute top-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-200",
"bg-transparent group-data-[active=true]/elastic-slider:bg-(--elastic-slider-hash)",
)}
style={{ left: `${hashMarkPct(i)}%` }}
/>
))}
</div>
<motion.div
data-slot="elastic-slider-fill"
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-y-0 left-0 transition-colors",
"bg-(--elastic-slider-fill) group-data-[active=true]/elastic-slider:bg-(--elastic-slider-fill-active)",
mat.enabled && "material-slider-fill",
)}
style={{ width: fillWidth }}
/>
<motion.div
data-slot="elastic-slider-handle"
aria-hidden="true"
className="pointer-events-none absolute top-1/2 h-5 w-1 rounded-full bg-(--elastic-slider-handle)"
style={{ left: handleLeft, y: "-50%" }}
animate={{
opacity: handleOpacity,
scaleX: isActive ? 1 : 0.25,
scaleY: isActive && valueDodge ? 0.75 : 1,
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
scaleX: {
type: "spring",
visualDuration: 0.25,
bounce: 0.15,
},
scaleY: { type: "spring", visualDuration: 0.2, bounce: 0.1 },
opacity: { duration: 0.15 },
}
}
/>
<span
ref={labelRef}
data-slot="elastic-slider-label"
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 inline-flex -translate-y-1/2 items-center text-sm/none font-medium text-(--elastic-slider-label) transition-colors"
>
{label}
</span>
<span
ref={valueRef}
data-slot="elastic-slider-value"
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 font-mono text-sm/none font-medium transition-colors",
"text-(--elastic-slider-label) group-data-[active=true]/elastic-slider:text-(--elastic-slider-focus)",
)}
>
{formatValue ? (
displayValue
) : (
<AnimateNumber format={animatedFormat}>{value}</AnimateNumber>
)}
</span>
</motion.div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { motion } from "motion/react";
import { useState } from "react";
import { Checkbox } from "./checkbox";
const meta: Meta<typeof Checkbox> = {
title: "form/Checkbox",
component: Checkbox,
argTypes: {
disabled: { control: "boolean" },
defaultChecked: { control: "boolean" },
},
};
export default meta;
type Story = StoryObj<typeof Checkbox>;
export const Default: Story = {};
export const DefaultChecked: Story = { args: { defaultChecked: true } };
export const Disabled: Story = { args: { disabled: true } };
export const Controlled: Story = {
render: () => {
const [checked, setChecked] = useState<boolean | "indeterminate">(false);
return (
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={checked} onCheckedChange={setChecked} />
Toggle me state: {String(checked)}
</label>
);
},
};
export const WithLabel: Story = {
render: () => (
<label className="flex items-center gap-2 text-sm">
<Checkbox defaultChecked />I agree to the terms
</label>
),
};
export const Indeterminate: Story = {
args: { checked: "indeterminate" },
};
export const EnterAnimation: Story = {
render: () => {
const [nonce, setNonce] = useState(0);
return (
<div className="flex flex-col items-start gap-3">
<button
type="button"
onClick={() => setNonce((n) => n + 1)}
className="rounded-button bg-primary px-3 py-1 text-sm text-neutral"
>
Replay enter
</button>
<motion.div
key={nonce}
initial="from"
animate="enter"
transition={{ delayChildren: 0.05, staggerChildren: 0.08 }}
className="flex items-center gap-3"
>
<Checkbox />
<Checkbox defaultChecked />
<Checkbox />
<Checkbox defaultChecked />
<Checkbox />
</motion.div>
</div>
);
},
};
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { motion } from "motion/react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { forwardRef } from "react";
import type * as React from "react";
import { useAnimation } from "@/animation/provider";
import { useControllableState } from "@/hooks/useControllableState";
import { cn } from "@/lib/utils";
import { useMaterial } from "@/material/provider";
import { useSound } from "@/sound/provider";
type CheckedState = boolean | "indeterminate";
type CheckboxRootProps = React.ComponentProps<typeof CheckboxPrimitive.Root>;
// Drop the DOM drag handlers so they don't collide with motion's drag types.
type MotionSafeCheckboxRootProps = Omit<
CheckboxRootProps,
"onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
>;
const CheckboxRoot = forwardRef<HTMLButtonElement, MotionSafeCheckboxRootProps>(
(props, ref) => <CheckboxPrimitive.Root ref={ref} {...props} />,
);
CheckboxRoot.displayName = "CheckboxRoot";
const MCheckboxRoot = motion.create(CheckboxRoot);
function Checkbox({
className,
onCheckedChange,
checked,
defaultChecked,
...props
}: MotionSafeCheckboxRootProps) {
const { play } = useSound("toggle");
const token = useAnimation("checkbox");
const mat = useMaterial("checkbox");
const [state, setState] = useControllableState<CheckedState>({
prop: checked,
defaultProp: defaultChecked ?? false,
onChange: onCheckedChange,
caller: "Checkbox",
});
const drawn = state === true || state === "indeterminate";
return (
<MCheckboxRoot
checked={checked}
defaultChecked={defaultChecked}
onCheckedChange={(next) => {
setState(next);
play();
}}
variants={token.variants}
transition={token.transition}
whileHover={token.whileHover}
whileTap={token.whileTap}
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-sm border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-neutral dark:data-checked:bg-primary",
mat.enabled && "material-check",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
forceMount
data-slot="checkbox-indicator"
className="grid place-content-center text-current"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<motion.path
d="M3 7.5 L6 10.5 L11 4.5"
initial={false}
animate={{
pathLength: drawn ? 1 : 0,
opacity: drawn ? 1 : 0,
}}
transition={{
pathLength: {
duration: 0.25,
ease: [0.65, 0, 0.35, 1],
},
opacity: {
duration: 0.1,
delay: drawn ? 0 : 0.15,
},
}}
/>
</svg>
</CheckboxPrimitive.Indicator>
</MCheckboxRoot>
);
}
export { Checkbox };
+29
View File
@@ -0,0 +1,29 @@
import { type HTMLMotionProps, motion } from "motion/react";
import type { ReactNode } from "react";
import { useAnimation } from "@/animation/provider";
import { cn } from "@/lib/utils";
type FieldContainerProps = {
children: ReactNode;
variants?: HTMLMotionProps<"div">["variants"];
transition?: HTMLMotionProps<"div">["transition"];
className?: string;
};
export const FieldContainer = ({
children,
variants,
transition,
className,
}: FieldContainerProps) => {
const token = useAnimation("fieldContainer");
return (
<motion.div
variants={variants ?? token.variants}
transition={transition ?? token.transition}
className={cn("py-3 flex flex-col gap-2", className)}
>
{children}
</motion.div>
);
};
+33
View File
@@ -0,0 +1,33 @@
import { cva } from "class-variance-authority";
import { motion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils.ts";
import { defaultAnimationStagger } from "@/styles/animations.ts";
export const formVariants = cva(cn("py-2 gap-2 max-w-max-form flex flex-col"));
export const Form = ({
children,
id,
onSubmit,
className,
}: {
children: ReactNode;
id?: string;
onSubmit?: () => void;
className?: string;
}) => {
return (
<motion.form
id={id}
onSubmit={(e) => {
e.preventDefault();
onSubmit?.();
}}
{...defaultAnimationStagger}
className={cn(formVariants(), className)}
>
{children}
</motion.form>
);
};
+73
View File
@@ -0,0 +1,73 @@
import * as React from "react";
import { InputText } from "./input-text";
type Props = Omit<
React.ComponentProps<"input">,
"value" | "onChange" | "type"
> & {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number | string;
};
const InputNumber = React.forwardRef<HTMLInputElement, Props>(
({ value, onChange, min, max, onBlur, onFocus, ...rest }, ref) => {
const [draft, setDraft] = React.useState<string>(String(value));
const [focused, setFocused] = React.useState(false);
React.useEffect(() => {
if (!focused && String(value) !== draft) {
setDraft(String(value));
}
}, [value, focused, draft]);
const commit = (raw: string) => {
if (raw === "" || raw === "-") return;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return;
if (min !== undefined && parsed < min) return;
if (max !== undefined && parsed > max) return;
if (parsed !== value) onChange(parsed);
};
return (
<InputText
ref={ref}
type="number"
inputMode="numeric"
min={min}
max={max}
value={draft}
onFocus={(e) => {
setFocused(true);
onFocus?.(e);
}}
onChange={(e) => {
const next = e.target.value;
setDraft(next);
commit(next);
}}
onBlur={(e) => {
setFocused(false);
const parsed = Number(draft);
if (draft === "" || !Number.isFinite(parsed)) {
setDraft(String(value));
} else {
let clamped = parsed;
if (min !== undefined && clamped < min) clamped = min;
if (max !== undefined && clamped > max) clamped = max;
setDraft(String(clamped));
if (clamped !== value) onChange(clamped);
}
onBlur?.(e);
}}
{...rest}
/>
);
},
);
InputNumber.displayName = "InputNumber";
export { InputNumber };
+32
View File
@@ -0,0 +1,32 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { useMaterial } from "@/material/provider";
import { useSound } from "@/sound/provider";
const InputText = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, type, onFocus, ...props }, ref) => {
const { play } = useSound("focus");
const mat = useMaterial("input");
return (
<input
onFocus={(e) => {
play();
onFocus?.(e);
}}
type={type}
className={cn(
"flex h-input-height w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
mat.enabled && "material-input",
className,
)}
ref={ref}
{...props}
/>
);
});
InputText.displayName = "InputText";
export { InputText };
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { Label as LabelPrimitive } from "radix-ui";
import * as React from "react";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium text-main leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { useMaterial } from "@/material/provider";
import { useSound } from "@/sound/provider";
function Select({
onOpenChange,
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
const { play: playOpen } = useSound("selectOpen");
const { play: playClose } = useSound("selectClose");
return (
<SelectPrimitive.Root
onOpenChange={(open) => {
if (open) playOpen();
else playClose();
onOpenChange?.(open);
}}
data-slot="select"
{...props}
/>
);
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
style,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
const mat = useMaterial("select");
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
data-material={mat.enabled ? "neutral" : undefined}
style={
mat.enabled
? ({ "--mat-base": "var(--neutral)", ...style } as React.CSSProperties)
: style
}
className={cn(
"border-main data-placeholder:text-secondary [&_svg:not([class*='text-'])]:text-secondary focus-visible:border-ring",
"focus-visible:ring-main/50 aria-invalid:ring-error/20 dark:aria-invalid:ring-error/40 aria-invalid:border-error",
"dark:bg-neutral-accent/30 dark:hover:bg-neutral-accent/50 flex w-fit items-center justify-between gap-2 rounded-lg",
"border bg-transparent px-4 py-2 text-sm text-main whitespace-nowrap shadow-xs transition-[color,box-shadow,background-color] outline-none",
"focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-input-height data-[size=sm]:h-8",
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center",
"*:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
mat.enabled && "material",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
style,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
const mat = useMaterial("select");
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-material={mat.enabled ? "neutral" : undefined}
style={
mat.enabled
? ({ "--mat-base": "var(--neutral)", ...style } as React.CSSProperties)
: style
}
className={cn(
"bg-neutral data-[state=open]:animate-in data-[state=closed]:animate-out p-1",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
"data-[side=top]:slide-in-from-bottom-2 relative z-100 max-h-(--radix-select-content-available-height) min-w-32",
"origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
mat.enabled && "material",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-main px-3 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
onMouseEnter,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
const { play } = useSound("hover");
return (
<SelectPrimitive.Item
onMouseEnter={(e) => {
play();
onMouseEnter?.(e);
}}
data-slot="select-item"
className={cn(
"focus:bg-main/25 text-main/75 focus:text-main [&_svg:not([class*='text-'])]:text-main transition-colors",
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-3",
"text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex",
"*:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-main pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { useMaterial } from "@/material/provider";
import { useSound } from "@/sound/provider";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, onFocus, ...props }, ref) => {
const { play } = useSound("focus");
const mat = useMaterial("input");
return (
<textarea
onFocus={(e) => {
play();
onFocus?.(e);
}}
className={cn(
"flex min-h-[5rem] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
mat.enabled && "material-input",
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };
+2
View File
@@ -0,0 +1,2 @@
declare module "*.mp3";
declare module "*.wav";
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react";
// use-layout-effect.tsx
// https://github.com/radix-ui/primitives/blob/main/packages/react/use-layout-effect/src/use-layout-effect.tsx
/**
* On the server, React emits a warning when calling `useLayoutEffect`.
* This is because neither `useLayoutEffect` nor `useEffect` run on the server.
* We use this safe version which suppresses the warning by replacing it with a noop on the server.
*
* See: https://reactjs.org/docs/hooks-reference.html#uselayouteffect
*/
const useLayoutEffect = globalThis?.document ? React.useLayoutEffect : () => {};
// use-controllable-state.tsx
// https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/use-controllable-state.tsx
// Prevent bundlers from trying to optimize the import
const useInsertionEffect: typeof useLayoutEffect =
(React as never)[" useInsertionEffect ".trim().toString()] || useLayoutEffect;
type ChangeHandler<T> = (state: T) => void;
type SetStateFn<T> = React.Dispatch<React.SetStateAction<T>>;
interface UseControllableStateParams<T> {
prop?: T | undefined;
defaultProp: T;
onChange?: ChangeHandler<T>;
caller?: string;
}
export function useControllableState<T>({
prop,
defaultProp,
onChange = () => {},
caller,
}: UseControllableStateParams<T>): [T, SetStateFn<T>] {
const [uncontrolledProp, setUncontrolledProp, onChangeRef] =
useUncontrolledState({
defaultProp,
onChange,
});
const isControlled = prop !== undefined;
const value = isControlled ? prop : uncontrolledProp;
// OK to disable conditionally calling hooks here because they will always run
// consistently in the same environment. Bundlers should be able to remove the
// code block entirely in production.
/* eslint-disable react-hooks/rules-of-hooks */
if (process.env.NODE_ENV !== "production") {
// biome-ignore lint/correctness/useHookAtTopLevel: <explanation>
const isControlledRef = React.useRef(prop !== undefined);
// biome-ignore lint/correctness/useHookAtTopLevel: <explanation>
React.useEffect(() => {
const wasControlled = isControlledRef.current;
if (wasControlled !== isControlled) {
const from = wasControlled ? "controlled" : "uncontrolled";
const to = isControlled ? "controlled" : "uncontrolled";
console.warn(
`${caller} is changing from ${from} to ${to}. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.`,
);
}
isControlledRef.current = isControlled;
}, [isControlled, caller]);
}
/* eslint-enable react-hooks/rules-of-hooks */
const setValue = React.useCallback<SetStateFn<T>>(
(nextValue) => {
if (isControlled) {
const value = isFunction(nextValue) ? nextValue(prop) : nextValue;
if (value !== prop) {
onChangeRef.current?.(value);
}
} else {
setUncontrolledProp(nextValue);
}
},
[isControlled, prop, setUncontrolledProp, onChangeRef],
);
return [value, setValue];
}
function useUncontrolledState<T>({
defaultProp,
onChange,
}: Omit<UseControllableStateParams<T>, "prop">): [
Value: T,
setValue: React.Dispatch<React.SetStateAction<T>>,
OnChangeRef: React.RefObject<ChangeHandler<T> | undefined>,
] {
const [value, setValue] = React.useState(defaultProp);
const prevValueRef = React.useRef(value);
const onChangeRef = React.useRef(onChange);
useInsertionEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
React.useEffect(() => {
if (prevValueRef.current !== value) {
onChangeRef.current?.(value);
prevValueRef.current = value;
}
}, [value, prevValueRef]);
return [value, setValue, onChangeRef];
}
function isFunction(value: unknown): value is (...args: never[]) => unknown {
return typeof value === "function";
}
+13
View File
@@ -0,0 +1,13 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function getRandomInt(min: number, max: number) {
const ceiledMin = Math.ceil(min);
const flooredMax = Math.floor(max);
return Math.floor(Math.random() * (flooredMax - ceiledMin) + ceiledMin);
}
+18
View File
@@ -0,0 +1,18 @@
import type { MaterialTheme } from "./types";
/**
* Everything OFF by default. With no <MaterialProvider> mounted (every game that
* hasn't opted in), components read this and render exactly as before so the
* material is a zero-regression, opt-in feature. A game enables the components
* it wants by passing a partial theme to <MaterialProvider>.
*/
export const defaultMaterialTheme: MaterialTheme = {
button: {},
card: {},
dialog: {},
tabs: {},
select: {},
input: {},
checkbox: {},
toast: {},
};
+15
View File
@@ -0,0 +1,15 @@
export { defaultMaterialTheme } from "./defaults";
export { mergeMaterialTheme } from "./merge";
export {
MaterialProvider,
type MaterialProviderProps,
useMaterial,
useMaterialTheme,
} from "./provider";
export type {
MaterialPreset,
MaterialTheme,
MaterialToken,
PartialMaterialTheme,
} from "./types";
export { isWebkit, useSpecular } from "./use-specular";
+24
View File
@@ -0,0 +1,24 @@
import type { MaterialTheme, PartialMaterialTheme } from "./types";
function setMerged<K extends keyof MaterialTheme>(
out: MaterialTheme,
key: K,
base: MaterialTheme,
partial: Partial<MaterialTheme[K]>,
) {
// Shallow per-token merge, matching the animation provider.
out[key] = { ...base[key], ...partial };
}
export function mergeMaterialTheme(
base: MaterialTheme,
override: PartialMaterialTheme,
): MaterialTheme {
const out = { ...base } as MaterialTheme;
for (const key of Object.keys(override) as (keyof MaterialTheme)[]) {
const partial = override[key];
if (!partial) continue;
setMerged(out, key, base, partial);
}
return out;
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { defaultMaterialTheme } from "./defaults";
import { mergeMaterialTheme } from "./merge";
import type { MaterialTheme, PartialMaterialTheme } from "./types";
const MaterialContext = createContext<MaterialTheme>(defaultMaterialTheme);
export type MaterialProviderProps = {
theme: PartialMaterialTheme;
children: ReactNode;
};
export function MaterialProvider({ theme, children }: MaterialProviderProps) {
const parent = useContext(MaterialContext);
// Enable material only AFTER mount. The material renders extra DOM (the
// `.material-fx` layer + `data-material`), so if the provider value differed
// between server and client it would cause a hydration mismatch (#418).
// Provider trees aren't always applied identically during SSR (e.g. the
// app shell/header), so to be safe-by-construction we provide the inherited
// value (default = OFF) during SSR and the first hydration render, then flip
// to the merged theme one tick later — exactly like the auth session bridge.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const merged = useMemo(
() => (mounted ? mergeMaterialTheme(parent, theme) : parent),
[mounted, parent, theme],
);
return (
<MaterialContext.Provider value={merged}>
{children}
</MaterialContext.Provider>
);
}
export function useMaterial<K extends keyof MaterialTheme>(
key: K,
): MaterialTheme[K] {
return useContext(MaterialContext)[key];
}
export function useMaterialTheme(): MaterialTheme {
return useContext(MaterialContext);
}
+33
View File
@@ -0,0 +1,33 @@
/** Which visual treatment a surface gets. */
export type MaterialPreset = "solid" | "neutral" | "none";
export type MaterialToken = {
/** Master switch for this component. Default off → no material, no regression. */
enabled?: boolean;
/**
* Follow the pointer with the specular hot-spot. Components additionally gate
* this on `isWebkit()` for large surfaces (cards) where the live-blur repaint
* bug is visible. Default true when enabled.
*/
trackPointer?: boolean;
/** Force a single preset regardless of the component's variant. */
preset?: MaterialPreset;
/** Per-variant preset overrides, keyed by the component's variant name. */
variants?: Record<string, MaterialPreset>;
};
/** Flat record keyed by component, mirroring AnimationTheme. */
export type MaterialTheme = {
button: MaterialToken;
card: MaterialToken;
dialog: MaterialToken;
tabs: MaterialToken;
select: MaterialToken;
input: MaterialToken;
checkbox: MaterialToken;
toast: MaterialToken;
};
export type PartialMaterialTheme = {
[K in keyof MaterialTheme]?: Partial<MaterialTheme[K]>;
};
+47
View File
@@ -0,0 +1,47 @@
"use client";
import type { PointerEvent } from "react";
import { useMemo } from "react";
/**
* True for WebKit engines desktop Safari and every iOS browser. These have a
* partial-repaint bug where moving content inside a `filter: blur()` layer only
* re-rasterises the dirty rect under the cursor, so pointer-tracked specular
* leaves trails. Chromium/Firefox are fine. `navigator.vendor` is
* "Apple Computer, Inc." for Safari + all iOS browsers, "Google Inc." for Chrome.
*/
export function isWebkit(): boolean {
return (
typeof navigator !== "undefined" &&
navigator.vendor === "Apple Computer, Inc."
);
}
const noop = (_e: PointerEvent<HTMLElement>) => {};
/**
* Pointer-tracked specular: updates `--mx` / `--my` on the hovered element so the
* `.material-spot` highlight follows the cursor (smoothed by the `@property`
* values in material.css). On leave it eases back to a top-centre rest.
*
* Pass `enabled = false` to get inert handlers (e.g. on WebKit, where tracking a
* live-blur layer flickers the highlight then stays put). Returns ready-to-
* spread handlers that read `e.currentTarget`, so no ref is needed.
*/
export function useSpecular(enabled = true) {
return useMemo(() => {
if (!enabled) return { onPointerMove: noop, onPointerLeave: noop };
return {
onPointerMove: (e: PointerEvent<HTMLElement>) => {
const el = e.currentTarget;
const r = el.getBoundingClientRect();
el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
},
onPointerLeave: (e: PointerEvent<HTMLElement>) => {
const el = e.currentTarget;
el.style.setProperty("--mx", "50%");
el.style.setProperty("--my", "30%");
},
};
}, [enabled]);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
let installed = false;
/**
* Reload once when a dynamic import of a route/chunk fails. This happens when a
* client still running a *previous* build navigates after a new deploy has
* replaced the hashed chunk files the lazy import 404s with "Importing a
* module script failed". Vite dispatches `vite:preloadError`; we reload so the
* user picks up the fresh build instead of crashing.
*
* - No-op on the server (guards `window`).
* - Idempotent safe to call more than once; only the first registers.
* - Time-guarded (`graceMs`, default 10s): if a chunk is genuinely missing the
* reload won't loop, but a later deploy in the same session can reload again.
*
* Call once at client startup, e.g. at module scope in the app's `router.tsx`.
*/
export function reloadOnStaleChunk(graceMs = 10_000): void {
if (typeof window === "undefined" || installed) return;
installed = true;
window.addEventListener("vite:preloadError", () => {
const KEY = "vite:preloadReloadedAt";
const last = Number(sessionStorage.getItem(KEY) ?? 0);
if (Date.now() - last < graceMs) return;
sessionStorage.setItem(KEY, String(Date.now()));
window.location.reload();
});
}
+21
View File
@@ -0,0 +1,21 @@
import type { SerializedHeadingNode } from "@payloadcms/richtext-lexical";
import type { JSXConverters } from "@payloadcms/richtext-lexical/react";
export const headingConverter: JSXConverters<SerializedHeadingNode> = {
heading: ({ node, nodesToJSX }) => {
if (node.tag === "h2") {
const text = nodesToJSX({ nodes: node.children });
const id = text
.join("")
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
return <h2 id={id}>{text}</h2>;
} else {
const text = nodesToJSX({ nodes: node.children }).join("");
const Tag = node.tag;
return <Tag>{text}</Tag>;
}
},
};
+25
View File
@@ -0,0 +1,25 @@
import type { DefaultNodeTypes } from "@payloadcms/richtext-lexical";
import {
type JSXConvertersFunction,
LinkJSXConverter,
} from "@payloadcms/richtext-lexical/react";
import { headingConverter } from "./headings";
import { internalDocToHref } from "./internal-link";
type NodeTypes = DefaultNodeTypes;
// Insert blocks like:
// | SerializedBlockNode<TableOfContentsProps | ContentWithMediaProps>;
export const jsxConverter: JSXConvertersFunction<NodeTypes> = ({
defaultConverters,
}) => ({
...defaultConverters,
...LinkJSXConverter({ internalDocToHref }),
...headingConverter,
blocks: {
/* contentWithMedia: ({ node }) => <ContentWithMedia {...node.fields} />,
tableOfContents: ({ node }) => <TableOfContents {...node.fields} />,
*/
},
});
+21
View File
@@ -0,0 +1,21 @@
import type { SerializedLinkNode } from "@payloadcms/richtext-lexical";
export const internalDocToHref = ({
linkNode,
}: {
linkNode: SerializedLinkNode;
}) => {
// biome-ignore lint/style/noNonNullAssertion: safe
const { value, relationTo } = linkNode.fields.doc!;
const slug =
typeof value !== "string" && typeof value !== "number" && value.slug;
if (relationTo === "posts") {
return `/posts/${slug}`;
} else if (relationTo === "users") {
return `/users/${slug}`;
} else {
return `/${slug}`;
}
};
+19
View File
@@ -0,0 +1,19 @@
import type { SerializedEditorState } from "@payloadcms/richtext-lexical/lexical";
import { RichText as RichTextConverter } from "@payloadcms/richtext-lexical/react";
import { jsxConverter } from "./converters";
type Props = {
data: SerializedEditorState;
} & React.HTMLAttributes<HTMLDivElement>;
export function RichText(props: Props) {
const { className, ...rest } = props;
return (
<RichTextConverter
{...rest}
className={className}
converters={jsxConverter}
/>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { motion } from "motion/react";
import type { FC, ReactElement, ReactNode } from "react";
import { useAnimation } from "@/animation/provider";
import { cn } from "@/lib/utils";
import Text from "@/text";
type HeadingProps = {
/*** Is this the main heading of the page? Uses h1 if true. */
main?: boolean;
/*** Subtitle for additional information */
subtitle?: ReactElement | string;
/*** IconComponent showed above title */
IconComponent?: ReactElement;
children?: ReactNode;
className?: string;
};
const headingVariants = cva("max-w-[800px]", {
variants: {
padding: {
true: "py-4",
false: "p-0",
},
color: {
main: "text-main",
secondary: "text-main/70",
neutral: "text-neutral",
},
},
defaultVariants: {
color: "main",
},
});
const Heading: FC<HeadingProps & VariantProps<typeof headingVariants>> = ({
children,
main = false,
IconComponent,
padding,
color,
subtitle,
className,
}) => {
const iconToken = useAnimation("headingIcon");
return (
<div className="relative mb-8">
{IconComponent && (
<>
<div
className="z-20 absolute w-full h-24 top-8 -left-8
bg-linear-to-t from-neutral to-transparent"
/>
<div className="relative z-10">
<motion.div
className="text-highlight
drop-shadow-[0px_0px_10px_hsl(var(--color-highlight)/0.5)]
w-fit h-fit"
transition={iconToken.transition}
variants={iconToken.variants}
>
{IconComponent}
</motion.div>
</div>
</>
)}
<div className="relative z-30 mb-2">
<Text
tag={main ? "h1" : "h2"}
className={cn(
headingVariants({ className, padding, color }),
"my-4",
subtitle ? "mb-2" : main ? "mb-2" : "mb-1",
)}
>
{children}
</Text>
{subtitle && (
<Text
tag="h3"
className="max-w-[600px]"
color={color === "neutral" ? "neutral" : "secondary"}
>
{subtitle}
</Text>
)}
</div>
</div>
);
};
export default Heading;
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { motion, useInView } from "motion/react";
import { createContext, type FC, useRef } from "react";
import { useAnimation } from "@/animation/provider";
import { cn } from "@/lib/utils";
import type { SectionProps } from "./section.types";
export const InViewContext = createContext(false);
const Section: FC<SectionProps> = ({
children,
name,
extraStyles,
maxWidth = true,
className,
extraClassNames,
threshold = 0,
height = "fit-content",
transition,
noPadding = false,
variants,
}) => {
const token = useAnimation("section");
const resolvedTransition = transition ?? token.transition;
const resolvedVariants = variants ?? token.variants;
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { once: true, amount: threshold });
return (
<section
{...(name ? { id: name } : {})}
ref={ref}
style={{
height: height,
...extraStyles,
}}
className={cn(
"relative w-full",
noPadding ? "p-0" : "p-main",
maxWidth ? "max-w-max-section m-auto" : "",
// `extraClassNames` is the legacy alias; merged in so old
// callers keep working until they migrate to `className`.
extraClassNames,
className,
)}
>
<InViewContext.Provider value={inView}>
<motion.div
className="h-full"
transition={resolvedTransition}
initial="from"
animate={inView ? "enter" : "from"}
exit="exit"
variants={resolvedVariants?.container}
>
{children}
</motion.div>
</InViewContext.Provider>
</section>
);
};
export default Section;
+26
View File
@@ -0,0 +1,26 @@
import type { Transition, Variant } from "motion/react";
import type React from "react";
export interface SectionProps {
children: React.ReactNode;
styles?: {
container?: string;
innerContainer?: string;
};
height?: string;
threshold?: number;
extraStyles?: object;
className?: string;
/**
* @deprecated Use `className` instead. Kept so existing callers keep
* working the value is merged into `className` at render time.
*/
extraClassNames?: string;
name?: string;
transition?: Transition;
noPadding?: boolean;
maxWidth?: boolean;
variants?: {
container: { enter: Variant; from: Variant; exit: Variant };
};
}
+67
View File
@@ -0,0 +1,67 @@
import type { SoundDef, SoundTheme } from "./types";
const buttonsSrc = new URL(
"../assets/sounds/762132__ienba__ui-buttons.wav",
import.meta.url,
).href;
const uiSetSrc = new URL(
"../assets/sounds/842498__newlocknew__uimvmt_game-user-interface-sound-set.mp3",
import.meta.url,
).href;
const buttonsSpriteMap = {
click1: [0, 1000],
click2: [2750, 1000],
click3: [5200, 1000],
click4: [7700, 1000],
} satisfies Record<string, [number, number]>;
const uiSetSpriteMap = {
smooth1: [0, 1200],
smooth2: [7800, 1400],
lobbyCreated: [53800, 3500],
gameStart: [62000, 4000],
} satisfies Record<string, [number, number]>;
const fromButtons = (
sprite: keyof typeof buttonsSpriteMap,
extra?: Partial<SoundDef>,
): SoundDef => ({
src: buttonsSrc,
sprite,
spriteMap: buttonsSpriteMap,
...extra,
});
const fromUiSet = (
sprite: keyof typeof uiSetSpriteMap,
extra?: Partial<SoundDef>,
): SoundDef => ({
src: uiSetSrc,
sprite,
spriteMap: uiSetSpriteMap,
...extra,
});
export const defaultSoundTheme: SoundTheme = {
volume: 1,
muted: false,
custom: {},
tokens: {
click: fromButtons("click1", { pool: 4, interrupt: true }),
hover: fromButtons("click2", { pool: 4, interrupt: true, volume: 0.6 }),
toggle: fromButtons("click2", { pool: 2 }),
focus: fromButtons("click2", { pool: 1, volume: 0.5 }),
selectOpen: fromButtons("click3"),
selectClose: fromButtons("click4"),
lobbyCreated: fromUiSet("lobbyCreated"),
gameStart: fromUiSet("gameStart"),
gameWon: fromUiSet("gameStart"),
roundSuccess: fromUiSet("smooth1"),
userJoined: fromUiSet("smooth1", { volume: 0.7 }),
userLeft: fromUiSet("smooth2", { volume: 0.7 }),
error: fromButtons("click4", { volume: 0.8 }),
submit: fromButtons("click3"),
vote: fromButtons("click2"),
},
};
+23
View File
@@ -0,0 +1,23 @@
export { defaultSoundTheme } from "./defaults";
export { mergeTheme } from "./merge";
export {
SoundProvider,
type SoundProviderProps,
useSound,
useSoundController,
useSoundSettings,
useSoundTheme,
} from "./provider";
export {
type AnySoundKey,
type CoreSoundKey,
type CustomSoundKey,
type PartialSoundTheme,
type PlayOptions,
type PlayUrlOptions,
type SoundController,
type SoundCustomTokens,
type SoundDef,
type SoundTheme,
WIN_SOUND_VOLUME,
} from "./types";
+23
View File
@@ -0,0 +1,23 @@
import type { PartialSoundTheme, SoundTheme } from "./types";
export function mergeTheme(
base: SoundTheme,
override: PartialSoundTheme,
): SoundTheme {
const tokens = { ...base.tokens };
if (override.tokens) {
for (const key of Object.keys(
override.tokens,
) as (keyof typeof tokens)[]) {
const next = override.tokens[key];
// `undefined` inherits from parent; explicit `null` silences this token.
if (next !== undefined) tokens[key] = next;
}
}
return {
tokens,
volume: override.volume ?? base.volume,
muted: override.muted ?? base.muted,
custom: { ...base.custom, ...(override.custom ?? {}) },
};
}
+88
View File
@@ -0,0 +1,88 @@
import type { Howl as HowlType } from "howler";
import type { SoundDef } from "./types";
let howlerPromise: Promise<typeof import("howler")> | null = null;
const loadHowler = () => (howlerPromise ??= import("howler"));
function howlKey(def: SoundDef): string {
const src = Array.isArray(def.src) ? def.src.join("|") : def.src;
if (!def.spriteMap) return src;
const fp = Object.entries(def.spriteMap)
.map(([k, [a, b]]) => `${k}:${a},${b}`)
.sort()
.join(";");
return `${src}#${fp}`;
}
const howlCache = new Map<string, Promise<HowlType>>();
export function getHowl(def: SoundDef): Promise<HowlType> {
const key = howlKey(def);
const cached = howlCache.get(key);
if (cached) return cached;
const created = loadHowler().then(
({ Howl }) =>
new Howl({
src: typeof def.src === "string" ? [def.src] : def.src,
sprite: def.spriteMap as
| Record<string, [number, number]>
| undefined,
preload: true,
pool: def.pool ?? 1,
}),
);
howlCache.set(key, created);
return created;
}
const URL_CACHE_MAX = 32;
const urlCache = new Map<string, Promise<HowlType>>();
export function getUrlHowl(url: string): Promise<HowlType> {
const cached = urlCache.get(url);
if (cached) return cached;
if (urlCache.size >= URL_CACHE_MAX) {
const oldest = urlCache.keys().next().value;
if (oldest) {
urlCache
.get(oldest)
?.then((h) => h.unload())
.catch(() => {});
urlCache.delete(oldest);
}
}
const created = loadHowler().then(
({ Howl }) =>
new Howl({ src: [url], html5: true, preload: false, pool: 1 }),
);
urlCache.set(url, created);
return created;
}
export function playDef(
def: SoundDef,
masterVolume: number,
callOpts?: { volume?: number; rate?: number },
): void {
getHowl(def)
.then((howl) => {
if (def.interrupt) howl.stop();
const id = def.sprite ? howl.play(def.sprite) : howl.play();
howl.volume((callOpts?.volume ?? def.volume ?? 1) * masterVolume, id);
const rate = callOpts?.rate ?? def.rate;
if (rate) howl.rate(rate, id);
})
.catch(() => {});
}
export function stopDef(def: SoundDef): void {
getHowl(def)
.then((howl) => howl.stop())
.catch(() => {});
}
export function stopAll(): void {
loadHowler()
.then(({ Howler }) => Howler.stop())
.catch(() => {});
}
+201
View File
@@ -0,0 +1,201 @@
"use client";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { defaultSoundTheme } from "./defaults";
import { mergeTheme } from "./merge";
import { getUrlHowl, playDef, stopAll as stopAllPlayers, stopDef } from "./player";
import type {
AnySoundKey,
CoreSoundKey,
PartialSoundTheme,
PlayOptions,
PlayUrlOptions,
SoundController,
SoundDef,
SoundTheme,
} from "./types";
const SoundContext = createContext<SoundTheme>(defaultSoundTheme);
const ControllerContext = createContext<SoundController | null>(null);
type PersistedSettings = { volume?: number; muted?: boolean };
function readPersisted(key: string): PersistedSettings | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return null;
return parsed as PersistedSettings;
} catch {
return null;
}
}
function writePersisted(key: string, value: PersistedSettings) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// quota / disabled — ignore
}
}
export type SoundProviderProps = {
theme: PartialSoundTheme;
/** When set, volume/mute persist to localStorage under this key. */
persistKey?: string;
children: ReactNode;
};
export function SoundProvider({
theme,
persistKey,
children,
}: SoundProviderProps) {
const parent = useContext(SoundContext);
// Default for SSR + the FIRST hydration render so server and client agree.
// Reading localStorage here instead would render e.g. the muted icon on the
// client but the unmuted icon on the server → React #418 (which regenerates
// the document and wipes the theme class). Persisted settings are applied
// only AFTER mount, one tick later.
const [overrides, setOverrides] = useState<
Pick<PartialSoundTheme, "volume" | "muted">
>({});
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
if (persistKey) {
const persisted = readPersisted(persistKey);
if (persisted) setOverrides(persisted);
}
setHydrated(true);
}, [persistKey]);
useEffect(() => {
// Don't persist until after the first read, or the initial {} clobbers it.
if (!persistKey || !hydrated) return;
writePersisted(persistKey, {
volume: overrides.volume,
muted: overrides.muted,
});
}, [persistKey, hydrated, overrides.volume, overrides.muted]);
const merged = useMemo(
() => mergeTheme(parent, { ...theme, ...overrides }),
[parent, theme, overrides],
);
// Stable themeRef so controller callbacks never need to re-create.
const themeRef = useRef(merged);
themeRef.current = merged;
const controller = useMemo<SoundController>(() => {
const resolve = (key: string): SoundDef | null => {
const t = themeRef.current;
if (key in t.tokens) return t.tokens[key as CoreSoundKey];
return t.custom[key] ?? null;
};
return {
resolve,
play: (key, opts?: PlayOptions) => {
const t = themeRef.current;
if (t.muted) return;
const def = resolve(key as string);
if (!def) return;
playDef(def, t.volume, opts);
},
playUrl: (url, opts?: PlayUrlOptions) => {
const t = themeRef.current;
if (t.muted) return;
getUrlHowl(url)
.then((howl) => {
if (opts?.stopOthers) howl.stop();
const id = howl.play();
howl.volume((opts?.volume ?? 1) * t.volume, id);
if (opts?.rate) howl.rate(opts.rate, id);
})
.catch(() => {});
},
stop: (key) => {
const def = resolve(key as string);
if (!def) return;
stopDef(def);
},
stopAll: () => stopAllPlayers(),
setVolume: (v) =>
setOverrides((o) => ({ ...o, volume: Math.max(0, Math.min(1, v)) })),
setMuted: (m) => setOverrides((o) => ({ ...o, muted: m })),
get volume() {
return themeRef.current.volume;
},
get muted() {
return themeRef.current.muted;
},
};
}, []);
return (
<SoundContext.Provider value={merged}>
<ControllerContext.Provider value={controller}>
{children}
</ControllerContext.Provider>
</SoundContext.Provider>
);
}
const noopController: SoundController = {
resolve: () => null,
play: () => {},
playUrl: () => {},
stop: () => {},
stopAll: () => {},
setVolume: () => {},
setMuted: () => {},
volume: 1,
muted: false,
};
export function useSoundController(): SoundController {
return useContext(ControllerContext) ?? noopController;
}
export function useSoundTheme(): SoundTheme {
return useContext(SoundContext);
}
/** Subscribe to a single token; returns referentially-stable `play`/`stop`. */
export function useSound(key: AnySoundKey | (string & {})) {
const ctrl = useSoundController();
return useMemo(
() => ({
play: (opts?: PlayOptions) => ctrl.play(key, opts),
stop: () => ctrl.stop(key),
}),
[ctrl, key],
);
}
/** Subscribe to the live master volume/mute values (for settings UIs that need to react). */
export function useSoundSettings() {
const theme = useSoundTheme();
const ctrl = useSoundController();
return useMemo(
() => ({
volume: theme.volume,
muted: theme.muted,
setVolume: ctrl.setVolume,
setMuted: ctrl.setMuted,
}),
[theme.volume, theme.muted, ctrl],
);
}
+78
View File
@@ -0,0 +1,78 @@
export type SoundDef = {
/** Absolute URL (or array for fallback formats). */
src: string | string[];
/** Optional sprite name within `src`. If omitted, plays the whole file. */
sprite?: string;
/** Sprite map declared once per asset; merged across SoundDefs that share `src`. */
spriteMap?: Record<string, [number, number]>;
/** 0..1, multiplied with the master volume. Defaults to 1. */
volume?: number;
/** Howler playbackRate. Defaults to 1. */
rate?: number;
/** Concurrent instances allowed. Defaults to 1. */
pool?: number;
/** Stop any in-flight instance before playing. Useful for hover sounds. */
interrupt?: boolean;
};
export type CoreSoundKey =
| "click"
| "hover"
| "toggle"
| "focus"
| "selectOpen"
| "selectClose"
| "lobbyCreated"
| "gameStart"
| "gameWon"
| "roundSuccess"
| "userJoined"
| "userLeft"
| "error"
| "submit"
| "vote";
/** Games augment this via `declare module "@unom/ui/sound"` to register custom token names. */
export interface SoundCustomTokens {}
export type CustomSoundKey = Extract<keyof SoundCustomTokens, string>;
export type SoundTheme = {
tokens: Record<CoreSoundKey, SoundDef | null>;
volume: number;
muted: boolean;
custom: Record<string, SoundDef | null>;
};
export type PartialSoundTheme = {
tokens?: Partial<Record<CoreSoundKey, SoundDef | null>>;
volume?: number;
muted?: boolean;
custom?: Record<string, SoundDef | null>;
};
export type PlayOptions = {
volume?: number;
rate?: number;
};
export type PlayUrlOptions = PlayOptions & {
/** Stop any in-flight instance of this URL before playing. */
stopOthers?: boolean;
};
export type AnySoundKey = CoreSoundKey | CustomSoundKey;
export type SoundController = {
resolve: (key: AnySoundKey | (string & {})) => SoundDef | null;
play: (key: AnySoundKey | (string & {}), opts?: PlayOptions) => void;
playUrl: (url: string, opts?: PlayUrlOptions) => void;
stop: (key: AnySoundKey | (string & {})) => void;
stopAll: () => void;
setVolume: (v: number) => void;
setMuted: (m: boolean) => void;
readonly volume: number;
readonly muted: boolean;
};
/** Shared default volume for user-uploaded "win" sounds. */
export const WIN_SOUND_VOLUME = 0.4;
+23
View File
@@ -0,0 +1,23 @@
import type { Transition, Variants } from "motion";
export const defaultTransitionCard: Transition = {
type: "spring",
stiffness: 200,
damping: 10,
mass: 1,
};
export const defaultAnimationStagger = {
variants: {
enter: {},
from: {},
},
transition: {
staggerChildren: 0.1,
},
};
export const defaultVariantsCard: Variants = {
enter: { y: 0, opacity: 1 },
from: { opacity: 0, y: 30 },
};
+234
View File
@@ -0,0 +1,234 @@
@import "./timing-functions.css" layer(base);
@import "./page-transition.css" layer(base);
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@source "./**/*.{js,mjs}";
:root {
--main: oklch(0.26% 0 0);
--accent: oklch(0.73 0.2 350.45);
--primary: oklch(0.56 0.17 253.49);
--brand: oklch(0.56 0.17 253.49);
--neutral: oklch(0.9 0.07 346.02);
--neutral-accent: oklch(0.92% 0.13 152.01);
--neutral-highlight: oklch(0.7448% 0.1823 50.477);
--highlight: oklch(0.7 0.23 352.23);
--success: oklch(91.1% 0.1605 148.89);
--error: oklch(65.7% 0.22923 26.238);
--font-display: "Integral CF", ui-sans-serif, system-ui, sans-serif;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono:
Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
--radius: 0.625rem;
/* Floor for the nested-card border radius see card.tsx. Each
theme can tune this independently of the base `--radius` to
keep deeply-nested cards visibly rounded. */
--radius-card-min: var(--radius);
}
@theme inline static {
--color-main: var(--main);
--color-secondary: var(--secondary);
--color-primary: var(--primary);
--color-accent: var(--accent);
--color-brand: var(--brand);
--color-neutral: var(--neutral);
--color-neutral-accent: var(--neutral-accent);
--color-neutral-highlight: var(--neutral-highlight);
--color-highlight: var(--highlight);
--color-success: var(--success);
--color-error: var(--error);
--font-display: var(--font-display);
--radius-card: calc(var(--radius) * 3);
--radius-button: 9999px;
--radius-main: calc(var(--radius) * 3);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 12px);
}
@theme {
--spacing-main: 15px;
--spacing-card: 1.5rem;
--spacing-section-main-x: 25px;
--spacing-section-main-y: 25px;
--spacing-input-height: 3rem;
--spacing-padding-card: 1.25rem;
--spacing-height-header: 80px;
--container-max-section: 100%;
--animate-collapsible-down: collapsible-down 0.6s var(--ease-out-quart);
--animate-collapsible-up: collapsible-up 0.6s var(--ease-out-quart);
--animate-accordion-down: accordion-down 0.6s var(--ease-out-quart);
--animate-accordion-up: accordion-up 0.6s var(--ease-out-quart);
@keyframes collapsible-down {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes collapsible-up {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
@variant dark {
:root {
--main: oklch(1 0 0);
--accent: oklch(0.83 0.12 346.59);
--secondary: oklch(0.8 0 0);
--brand: oklch(0.73 0.2 350.45);
--primary: oklch(0.73 0.2 350.45);
--neutral: oklch(0.2 0.03 245.99);
--neutral-highlight: oklch(0.73 0.2 350.45);
--neutral-accent: oklch(0.17 0.02 236.04);
--highlight: oklch(66.39% 0.2398 3.2);
--success: oklch(91.1% 0.1605 148.89);
--error: oklch(67.36% 0.2339 0.92);
}
}
@variant lg {
:root {
--container-max-section: 1000px;
--spacing-height-header: 90px;
}
}
@variant xl {
:root {
--container-max-section: 1500px;
--spacing-section-main-x: 100px;
--spacing-section-main-y: 45px;
--spacing-height-header: 100px;
}
}
@layer base {
html {
@apply bg-neutral text-main font-sans;
scroll-behavior: smooth;
}
code {
@apply font-mono;
}
h1 {
@apply text-main font-display;
}
h1 {
@apply text-3xl font-bold;
}
h2 {
@apply text-2xl font-semibold;
}
h3 {
@apply text-xl font-medium;
}
h4 {
@apply text-lg;
}
a {
@apply underline;
}
a:hover {
@apply decoration-main text-main;
}
td {
@apply px-3 py-1 border;
}
article {
& h1,
h2,
h3,
h4,
h5 {
@apply mt-6;
}
& h1,
h2,
h3,
h4,
h5,
p,
ol,
ul {
@apply mb-4;
}
}
p,
li {
@apply max-w-150 text-base text-secondary;
line-height: 1.5;
}
.richtext {
& h1,
h2,
h3,
h4,
h5,
p,
ol,
ul {
@apply mb-4 mt-6;
}
}
}
+309
View File
@@ -0,0 +1,309 @@
/* ============================================================================
@unom/ui material system skeuomorphic "jelly" surfaces.
----------------------------------------------------------------------------
OPT-IN: `@import "@unom/ui/styles/material.css"` in your globals.css AND
mount <MaterialProvider> (from "@unom/ui/material"). The provider is OFF by
default, so components only emit `.material` + `data-material` when a game
opts in games that don't are unaffected.
THEMING: colours derive from your palette via color-mix off one `--mat-base`
(the components set it: solid buttons --primary / --error, neutral surfaces
--neutral / --neutral-accent). Override any `--mat-*` knob in your :root.
SAFARI: the specular is a blurred `.material-fx` layer where filter + clip-path
live on the SAME element (children are plain) the only Safari-safe way to
clip a blur. Live blur has a WebKit partial-repaint bug under pointer movement,
so components gate pointer tracking via isWebkit() (see use-specular.ts).
============================================================================ */
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 30%;
}
:root {
--mat-blur: 15px; /* specular softness */
--mat-glow: 0.67; /* solid-preset specular intensity */
--mat-glow-hover: 0.92; /* solid intensity on hover/focus */
--mat-rim: 0.25; /* bottom Fresnel rim */
--mat-sheen-opacity: 0.45; /* broad top sheen strength */
--mat-spot-opacity: 0.9; /* tight hot-spot strength */
--mat-blend: screen; /* specular compositing */
--mat-spec-h: 320; /* specular hue */
--mat-neutral-glow: 0.5; /* neutral-preset specular intensity */
--mat-neutral-glow-hover: 0.72;
--mat-card-glow: 0.28; /* cards are large → softer specular */
--mat-card-glow-hover: 0.42;
--mat-card-ring: var(
--accent
); /* cards keep an accent ring (was ring-accent) */
--mat-card-tint: 50%; /* how far the card's shade side travels to accent */
}
/* surface host: derived colours + stacking context for the fx layer */
.material {
--mat-base: var(--primary);
--mat-lit: color-mix(in oklch, var(--mat-base), white 30%);
--mat-shade: color-mix(in oklch, var(--mat-base), black 14%);
--mat-bevel: color-mix(in oklch, var(--mat-base), white 72%);
--mat-spec: oklch(0.97 0.03 var(--mat-spec-h));
--mat-rimcol: oklch(0.99 0.03 var(--mat-spec-h) / var(--mat-rim));
--mat-radius: 16px;
/* No `position` here: fx-bearing components add `relative` themselves, so a
positioned host (e.g. a `fixed` dialog) is never overridden. */
isolation: isolate;
}
/* The single blurred specular layer. z-index:-1 keeps it BEHIND the element's
own content (so children need no wrapping) but ABOVE its background the
isolation:isolate above contains the negative index. filter + clip-path on the
SAME element is the Safari-safe clip. */
.material-fx {
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
filter: blur(var(--mat-blur));
clip-path: inset(0 round var(--mat-radius));
mix-blend-mode: var(--mat-blend, screen);
opacity: var(--mat-glow);
transition: opacity 0.28s ease;
/* Promote to a GPU-backed layer so WebKit rasterises the (static) blur once
into a cached texture instead of re-blurring on the CPU; only opacity
animates (hover), which is cheap on a composited layer. `will-change:
filter` is intentionally NOT set hinting the filter changes defeats that
cache and is what pushed Safari onto the CPU blur path. */
transform: translateZ(0);
}
.material-fx > i {
position: absolute;
display: block;
border-radius: 50%;
}
.material:hover > .material-fx,
.material:focus-visible > .material-fx {
opacity: var(--mat-glow-hover);
}
/* broad, mostly-static top sheen (soft environment reflection).
Height is FIXED (not %) so it stays a top band on tall buttons instead of
stretching down with the element; width still grows with the button. Cards
override these with their own larger fixed band below. */
.material-sheen {
left: 50%;
top: -28px;
transform: translateX(-50%);
width: 88%;
height: 64px;
background: var(--mat-spec);
opacity: var(--mat-sheen-opacity);
}
/* tight specular hot-spot — tracks the pointer near the top */
.material-spot {
left: var(--mx, 50%);
top: -8px;
transform: translateX(-50%);
width: 26%;
height: 32px;
background: #fff;
opacity: var(--mat-spot-opacity);
}
/* ── PRESET: solid (filled colour — default/destructive buttons, checkbox) ── */
[data-material="solid"] {
background: linear-gradient(
to bottom,
var(--mat-lit) 0%,
var(--mat-base) 50%,
var(--mat-shade) 100%
);
box-shadow:
inset 0 2px 0 var(--mat-bevel),
inset 0 0 0 1px oklch(0.99 0.03 var(--mat-spec-h) / 0.35),
inset 0 -3px 3px var(--mat-rimcol),
inset 0 -16px 22px -10px oklch(0 0 0 / 0.35),
inset 0 8px 18px -6px oklch(0.99 0.05 var(--mat-spec-h) / 0.25),
0 12px 26px -8px oklch(0 0 0 / 0.45),
0 2px 5px oklch(0 0 0 / 0.3);
text-shadow:
0 1px 1px oklch(0 0 0 / 0.32),
0 -0.5px 0 oklch(1 0 0 / 0.12);
}
/* ── PRESET: neutral (cards + outline/secondary buttons; keep ring/border) ── */
[data-material="neutral"] {
background: linear-gradient(
to bottom,
color-mix(in oklch, var(--mat-base), white 10%) 0%,
var(--mat-base) 55%,
color-mix(in oklch, var(--mat-base), black 7%) 100%
);
box-shadow:
inset 0 1px 0 color-mix(in oklch, var(--mat-base), white 60%),
inset 0 -2px 3px var(--mat-rimcol),
inset 0 -10px 16px -10px oklch(0 0 0 / 0.14);
}
[data-material="neutral"] > .material-fx {
opacity: var(--mat-neutral-glow);
}
[data-material="neutral"]:hover > .material-fx,
[data-material="neutral"]:focus-visible > .material-fx {
opacity: var(--mat-neutral-glow-hover);
}
/* Large surfaces (cards): the %-sized sheen/spot grow with the card while the
blur stays fixed, so on big cards they turn into hard blobs. Pin them to a
fixed-size, more-heavily-blurred top band so a card of any height reads as a
soft top sheen rather than a washed-out half. */
.material-card {
/* Accent SHADING: the face stays a light neutral but shades into the accent
toward the bottom, and the inner depth shadow is accent-coloured instead of
grey so the card reads as lit neutral with an accent-coloured shadow/ambient.
Tinting the *whole* surface washes out (neutral accent hue here), so the
accent lives on the shade side. --mat-card-tint = how far that shade travels. */
--mat-card-shade: color-mix(
in oklch,
var(--mat-base),
var(--accent) var(--mat-card-tint, 50%)
);
background: linear-gradient(
180deg,
color-mix(in oklch, var(--mat-base), white 12%) 0%,
var(--mat-base) 42%,
var(--mat-card-shade) 100%
);
box-shadow:
0 0 0 2px var(--mat-card-ring),
inset 0 1px 0 color-mix(in oklch, var(--mat-base), white 55%),
inset 0 -2px 3px var(--mat-rimcol),
inset 0 -16px 26px -14px color-mix(in oklch, var(--accent), black 8%);
}
.material-card > .material-fx {
filter: blur(calc(var(--mat-blur) * 1.8));
opacity: var(--mat-card-glow);
}
.material-card:hover > .material-fx,
.material-card:focus-visible > .material-fx {
opacity: var(--mat-card-glow-hover);
}
.material-card .material-sheen {
top: -56px;
height: 200px;
width: 92%;
}
.material-card .material-spot {
top: -32px;
height: 130px;
width: 34%;
}
/* ── INPUTS: recessed/carved surface, no fx layer (inputs are void elements) ── */
.material-input {
background: color-mix(in oklch, var(--neutral, oklch(0.95 0 0)), black 3%);
box-shadow:
inset 0 2px 4px oklch(0 0 0 / 0.12),
inset 0 1px 1px oklch(0 0 0 / 0.08),
0 1px 0 color-mix(in oklch, var(--neutral, oklch(1 0 0)), white 40%);
transition: box-shadow 0.2s ease;
}
.material-input:focus,
.material-input:focus-visible {
box-shadow:
inset 0 2px 4px oklch(0 0 0 / 0.1),
0 0 0 3px
color-mix(in oklch, var(--primary, oklch(0.6 0.15 250)), transparent 62%);
}
/* small / stateful controls: surface only, no fx layer
These derive directly from the palette (no --mat-base needed) and only paint
their surface in the relevant state, so unchecked/inactive controls stay flat. */
/* checkbox — glossy fill only when checked */
.material-check[data-state="checked"],
.material-check[data-state="indeterminate"] {
background: linear-gradient(
to bottom,
color-mix(in oklch, var(--primary), white 30%) 0%,
var(--primary) 55%,
color-mix(in oklch, var(--primary), black 14%) 100%
);
box-shadow:
inset 0 1px 0 color-mix(in oklch, var(--primary), white 60%),
inset 0 -1px 2px oklch(0 0 0 / 0.3),
0 1px 2px oklch(0 0 0 / 0.3);
}
/* tabs + toggle-group recessed list, raised active item. Tabs mark the active
trigger with data-state="active"; radix ToggleGroup uses data-state="on". */
.material-tablist {
box-shadow:
inset 0 1px 3px oklch(0 0 0 / 0.12),
inset 0 -1px 0 color-mix(in oklch, var(--neutral-accent), white 25%);
}
.material-tab[data-state="active"],
.material-tab[data-state="on"] {
background: linear-gradient(
to bottom,
color-mix(in oklch, var(--neutral), white 14%) 0%,
var(--neutral) 60%,
color-mix(in oklch, var(--neutral), black 6%) 100%
);
box-shadow:
inset 0 1px 0 color-mix(in oklch, var(--neutral), white 70%),
inset 0 -1px 2px oklch(0 0 0 / 0.12),
0 1px 2px oklch(0 0 0 / 0.2);
}
/* toast — glossy neutral panel (class-based; Sonner is styled via classNames) */
.material-toast {
background: linear-gradient(
to bottom,
color-mix(in oklch, var(--neutral), white 10%) 0%,
var(--neutral) 55%,
color-mix(in oklch, var(--neutral), black 7%) 100%
);
box-shadow:
inset 0 1px 0 color-mix(in oklch, var(--neutral), white 55%),
inset 0 -2px 3px
oklch(0.99 0.03 var(--mat-spec-h, 320) / var(--mat-rim, 0.25)),
0 12px 26px -8px oklch(0 0 0 / 0.4);
}
/* elastic slider material: a carved groove track + a glossy primary fill */
.material-slider-track {
background: linear-gradient(
180deg,
color-mix(in oklch, var(--neutral), black 9%),
color-mix(in oklch, var(--neutral), black 2%)
);
box-shadow:
inset 0 2px 5px oklch(0 0 0 / 0.2),
inset 0 1px 1px oklch(0 0 0 / 0.12),
inset 0 -1px 0 color-mix(in oklch, var(--neutral), white 35%);
}
.material-slider-fill {
background: linear-gradient(
180deg,
color-mix(in oklch, var(--primary), white 32%) 0%,
var(--primary) 55%,
color-mix(in oklch, var(--primary), black 12%) 100%
);
}
@media (prefers-reduced-motion: reduce) {
.material-spot {
left: 50% !important;
top: -18% !important;
}
.material-fx,
.material-input {
transition: none;
}
}
+18
View File
@@ -0,0 +1,18 @@
@keyframes pageEnter {
from {
transform: translateY(40px);
opacity: 0;
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pageLeave {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
+128
View File
@@ -0,0 +1,128 @@
/*
* Default theme for @unom/ui's own Storybook (and other isolated
* environments). Consumers ship their own globals.css with their palette
* + tokens this file exists only so components have a working theme
* when rendered in isolation here.
*
* Consumers should NOT import this file.
*/
@import "tailwindcss";
@import "tw-animate-css";
@source "../**/*.{ts,tsx,js,mjs}";
:root {
--main: oklch(0.2 0 0);
--secondary: oklch(0.4 0 0);
--accent: oklch(0.6 0.15 250);
--primary: oklch(0.6 0.15 250);
--brand: oklch(0.6 0.15 250);
--neutral: oklch(1 0 0);
--neutral-accent: oklch(0.96 0 0);
--neutral-highlight: oklch(0.9 0 0);
--highlight: oklch(0.6 0.15 250);
--success: oklch(91.1% 0.1605 148.89);
--error: oklch(65.7% 0.22923 26.238);
--font-display: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Courier New", monospace;
--radius: 0.625rem;
/* Floor for the nested-card border radius see card.tsx. Each
theme can tune this independently of the base `--radius` to
keep deeply-nested cards visibly rounded. */
--radius-card-min: var(--radius);
}
@theme inline static {
--color-main: var(--main);
--color-secondary: var(--secondary);
--color-primary: var(--primary);
--color-accent: var(--accent);
--color-brand: var(--brand);
--color-neutral: var(--neutral);
--color-neutral-accent: var(--neutral-accent);
--color-neutral-highlight: var(--neutral-highlight);
--color-highlight: var(--highlight);
--color-success: var(--success);
--color-error: var(--error);
--font-display: var(--font-display);
--font-sans: var(--font-sans);
--font-serif: var(--font-serif);
--font-mono: var(--font-mono);
--radius-card: calc(var(--radius) * 3);
--radius-button: 9999px;
--radius-main: calc(var(--radius) * 3);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 12px);
}
@theme {
--spacing-main: 15px;
--spacing-card: 1.5rem;
--spacing-input-height: 3rem;
--spacing-padding-card: 1.25rem;
--spacing-height-header: 80px;
--container-max-section: 100%;
--animate-collapsible-down: collapsible-down 0.6s ease-out;
--animate-collapsible-up: collapsible-up 0.6s ease-out;
--animate-accordion-down: accordion-down 0.6s ease-out;
--animate-accordion-up: accordion-up 0.6s ease-out;
@keyframes collapsible-down {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes collapsible-up {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
html,
body {
font-family: var(--font-sans);
background: var(--neutral);
color: var(--main);
}
+23
View File
@@ -0,0 +1,23 @@
@theme {
--ease-in-sine: cubic-bezier(0.47, 0, 0.745, 0.715);
--ease-out-sine: cubic-bezier(0.39, 0.575, 0.565, 1);
--ease-in-out-sine: cubic-bezier(0.445, 0.05, 0.55, 0.95);
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
--ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
--ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { Tabs as TabsPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { useMaterial } from "@/material/provider";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-4", className)}
{...props}
/>
);
}
// Mobile-first default: when triggers don't fit, the list scrolls
// horizontally instead of wrapping. Wrapping made the background pill
// (bg-neutral-accent) lag behind the second row and looked broken. Set
// `w-full` plus `[&>button]:flex-1` on the list itself if you want the
// desktop "equal-width" layout — the new defaults are scroll-friendly.
//
// Scrollbar is hidden so the row feels like a native tab strip; users
// know they can swipe because the active tab scrolls into view (Radix
// does this automatically) and adjacent triggers stay partially visible
// at the edge.
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
const mat = useMaterial("tabs");
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-neutral-accent text-secondary flex h-input-height w-fit max-w-full items-center justify-start rounded-lg p-[3px] overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
mat.enabled && "material-tablist",
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
const mat = useMaterial("tabs");
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-neutral data-[state=active]:text-main data-[state=active]:shadow-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] inline-flex h-[calc(var(--spacing-input-height)-6px)] shrink-0 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:opacity-50",
mat.enabled && "material-tab",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn(
"flex-1 outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] rounded-md",
className,
)}
{...props}
/>
);
}
export { Tabs, TabsContent, TabsList, TabsTrigger };
+46
View File
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import Text from "./text";
const meta: Meta<typeof Text> = {
title: "text/Text",
component: Text,
argTypes: {
tag: {
control: "select",
options: ["h1", "h2", "h3", "h4", "h5", "p", "span"],
},
color: {
control: "select",
options: ["default", "main", "secondary", "neutral"],
},
},
};
export default meta;
type Story = StoryObj<typeof Text>;
export const Heading: Story = {
args: { tag: "h1", children: "The quick brown fox" },
};
export const Paragraph: Story = {
args: {
tag: "p",
children:
"Paragraph text — soft secondary color by default in this design system.",
},
};
export const Scale: Story = {
render: () => (
<div className="flex flex-col gap-2">
<Text tag="h1">Heading 1</Text>
<Text tag="h2">Heading 2</Text>
<Text tag="h3">Heading 3</Text>
<Text tag="h4">Heading 4</Text>
<Text tag="p">Body paragraph</Text>
<Text tag="span">Inline span</Text>
</div>
),
};
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { type HTMLMotionProps, motion } from "motion/react";
import { type FC, forwardRef, type HTMLProps } from "react";
import { z } from "zod";
import { useAnimation } from "@/animation/provider";
import { cn } from "@/lib/utils";
const TextTagHeadingOptions = ["h1", "h2", "h3", "h4", "h5"] as const;
const TextTagContentOptions = ["span", "p"] as const;
export const TextTag = z.enum([
...TextTagHeadingOptions,
...TextTagContentOptions,
]);
const textVariants = cva(null, {
variants: {
color: {
default: "",
main: "",
secondary: "text-secondary",
neutral: "text-neutral",
},
},
});
type Tags = z.output<typeof TextTag>;
type TextProps = {
tag: Tags;
} & HTMLProps<HTMLHeadingElement> &
VariantProps<typeof textVariants>;
const Text = forwardRef<HTMLHeadingElement, TextProps>(
({ tag, color, className, ...props }, ref) => {
const Component = tag;
return (
<Component
ref={ref}
className={cn(textVariants({ color, className }))}
{...props}
>
{props.children}
</Component>
);
},
);
Text.displayName = "Text";
const MText = motion.create(Text);
const AnimatedText: FC<TextProps & HTMLMotionProps<Tags>> = ({
variants,
transition,
...props
}) => {
const token = useAnimation("text");
return (
<MText
variants={variants ?? token.variants}
transition={transition ?? token.transition}
ref={props.ref}
{...props}
/>
);
};
export default AnimatedText;
+101
View File
@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "./button";
import { toast, Toaster } from "./toast";
const meta: Meta<typeof Toaster> = {
title: "toast/Toast",
component: Toaster,
};
export default meta;
type Story = StoryObj<typeof Toaster>;
export const Default: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
<Toaster />
<Button onClick={() => toast("Hello from a toast")}>Message</Button>
<Button
variant="outline"
onClick={() => toast.success("Saved successfully")}
>
Success
</Button>
<Button
variant="destructive"
onClick={() => toast.error("Something went wrong")}
>
Error
</Button>
<Button variant="secondary" onClick={() => toast.info("Heads up")}>
Info
</Button>
<Button variant="ghost" onClick={() => toast.warning("Careful now")}>
Warning
</Button>
</div>
),
};
export const WithDescription: Story = {
render: () => (
<>
<Toaster />
<Button
onClick={() =>
toast("Event created", {
description: "Friday, March 21st at 1:57pm",
})
}
>
Show toast
</Button>
</>
),
};
export const WithAction: Story = {
render: () => (
<>
<Toaster />
<Button
onClick={() =>
toast("File moved to trash", {
action: {
label: "Undo",
onClick: () => toast.success("Restored"),
},
})
}
>
Move to trash
</Button>
</>
),
};
export const Promise: Story = {
render: () => (
<>
<Toaster />
<Button
onClick={() =>
toast.promise(
new globalThis.Promise<{ name: string }>((resolve) =>
setTimeout(() => resolve({ name: "report.pdf" }), 1500),
),
{
loading: "Uploading...",
success: (data) =>
`${(data as { name: string }).name} uploaded`,
error: "Upload failed",
},
)
}
>
Upload
</Button>
</>
),
};
+99
View File
@@ -0,0 +1,99 @@
"use client";
import type { ComponentProps, FC } from "react";
import { useEffect, useState } from "react";
import { useMaterial } from "@/material/provider";
// `sonner` runs `React.createElement` at module top-level, so we load it
// dynamically and gate on `window` to keep this module safe to evaluate
// during SSR. Letting Vite/Rollup chunk the dynamic import normally
// produces a resolvable URL in the client bundle.
type SonnerModule = typeof import("sonner");
let cached: SonnerModule | null = null;
let pending: Promise<SonnerModule> | null = null;
function ensureSonner(): Promise<SonnerModule> | null {
if (typeof window === "undefined") return null;
if (cached) return Promise.resolve(cached);
if (!pending) {
pending = import("sonner").then((m: SonnerModule) => {
cached = m;
return m;
});
}
return pending;
}
const Toaster: FC<ComponentProps<SonnerModule["Toaster"]>> = (props) => {
const [mod, setMod] = useState<SonnerModule | null>(cached);
const mat = useMaterial("toast");
useEffect(() => {
if (mod) return;
const p = ensureSonner();
if (!p) return;
let active = true;
p.then((m) => {
if (active) setMod(m);
});
return () => {
active = false;
};
}, [mod]);
if (!mod) return null;
return (
<mod.Toaster
position="top-right"
toastOptions={{
classNames: {
toast: `group rounded-card border bg-neutral text-main shadow-lg gap-3 p-4 backdrop-blur-sm${mat.enabled ? " material-toast" : ""}`,
title: "font-semibold text-main",
description: "text-main/70 text-sm",
actionButton: "rounded-button bg-primary text-neutral px-3 py-1",
cancelButton: "rounded-button bg-neutral-accent text-main px-3 py-1",
icon: "text-accent",
},
}}
{...props}
/>
);
};
type ToastApi = SonnerModule["toast"];
type ToastFn = ((...args: Parameters<ToastApi>) => void) & {
[K in keyof ToastApi]: ToastApi[K] extends (...args: infer A) => unknown
? (...args: A) => void
: ToastApi[K];
};
function invoke(method: string | null, args: unknown[]): void {
const p = ensureSonner();
if (!p) return;
void p.then((m) => {
const target =
method === null ? m.toast : (m.toast as unknown as Record<string, unknown>)[method];
if (typeof target === "function") {
(target as (...a: unknown[]) => unknown)(...args);
}
});
}
const toastImpl = ((...args: unknown[]) => invoke(null, args)) as unknown as ToastFn;
for (const method of [
"success",
"error",
"info",
"warning",
"message",
"loading",
"dismiss",
"custom",
"promise",
] as const) {
(toastImpl as unknown as Record<string, unknown>)[method] = (...args: unknown[]) =>
invoke(method, args);
}
const toast = toastImpl;
export { Toaster, toast };
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { useMaterial } from "@/material/provider";
/**
* shadcn-style single/multi ToggleGroup (radix), themed with the unom tokens:
* a recessed `--neutral-accent` track with a raised `--neutral` active item
* matching the Tabs strip. When the game opts into the material it reuses the
* Tabs material classes (`material-tablist` / `material-tab`). Use for
* mutually-exclusive segmented filters / view switches.
*/
function ToggleGroup({
className,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root>) {
const mat = useMaterial("tabs");
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
className={cn(
"inline-flex h-input-height w-fit items-center gap-0.5 rounded-lg bg-neutral-accent p-[3px] text-secondary",
mat.enabled
? "material-tablist"
: "shadow-[inset_0_1px_3px_oklch(0_0_0/0.12)]",
className,
)}
{...props}
/>
);
}
function ToggleGroupItem({
className,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item>) {
const mat = useMaterial("tabs");
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
className={cn(
"inline-flex h-[calc(var(--spacing-input-height)-6px)] shrink-0 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap outline-none transition-[color,background-color,box-shadow]",
"hover:text-main focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:opacity-50",
"data-[state=on]:bg-neutral data-[state=on]:text-main data-[state=on]:shadow-sm",
mat.enabled && "material-tab",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
export { ToggleGroup, ToggleGroupItem };
+140
View File
@@ -0,0 +1,140 @@
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import {
type ReactNode,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
type Props<T> = {
items: T[];
columns: number;
estimateRowHeight: number;
renderItem: (item: T, index: number) => ReactNode;
getItemKey: (item: T, index: number) => string;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onLoadMore?: () => void;
overscan?: number;
className?: string;
rowClassName?: string;
rowGap?: string;
loadingRow?: ReactNode;
};
export function VirtualizedGrid<T>({
items,
columns,
estimateRowHeight,
renderItem,
getItemKey,
hasNextPage,
isFetchingNextPage,
onLoadMore,
overscan = 3,
className,
rowClassName,
rowGap = "var(--spacing-card)",
loadingRow,
}: Props<T>) {
const parentRef = useRef<HTMLDivElement | null>(null);
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
const update = () => {
if (!parentRef.current) return;
const rect = parentRef.current.getBoundingClientRect();
setScrollMargin(rect.top + window.scrollY);
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
const rowCount = Math.ceil(items.length / columns);
const totalCount = rowCount + (hasNextPage ? 1 : 0);
const rowKeys = useMemo(() => {
const keys: string[] = [];
for (let row = 0; row < rowCount; row++) {
const first = items[row * columns];
keys.push(first ? getItemKey(first, row * columns) : `row-${row}`);
}
return keys;
}, [items, rowCount, columns, getItemKey]);
const virtualizer = useWindowVirtualizer({
count: totalCount,
estimateSize: () => estimateRowHeight,
overscan,
scrollMargin,
getItemKey: (index) =>
index < rowCount
? (rowKeys[index] ?? `row-${index}`)
: `sentinel-${index}`,
});
const virtualRows = virtualizer.getVirtualItems();
useEffect(() => {
if (!hasNextPage || !onLoadMore || isFetchingNextPage) return;
const last = virtualRows[virtualRows.length - 1];
if (last && last.index >= rowCount) {
onLoadMore();
}
}, [virtualRows, rowCount, hasNextPage, isFetchingNextPage, onLoadMore]);
return (
<div
ref={parentRef}
className={className}
style={{ height: virtualizer.getTotalSize(), position: "relative" }}
>
{virtualRows.map((virtualRow) => {
const isSentinel = virtualRow.index >= rowCount;
const rowItems = isSentinel
? []
: items.slice(
virtualRow.index * columns,
virtualRow.index * columns + columns,
);
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start - virtualizer.options.scrollMargin}px)`,
}}
>
{isSentinel ? (
loadingRow
) : (
<div
className={cn("grid", rowClassName)}
style={{ paddingBottom: rowGap }}
>
{rowItems.map((item, i) => {
const absoluteIndex = virtualRow.index * columns + i;
return (
<div key={getItemKey(item, absoluteIndex)}>
{renderItem(item, absoluteIndex)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"paths": {
"@/*": ["./src/*"]
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig } from "tsdown";
export default defineConfig({
fixedExtension: false,
entry: [
"./src/**/*.(ts|tsx)",
"!./src/**/*.stories.(ts|tsx)",
],
format: "esm",
copy: [
{
from: "./src/styles/*.css",
to: "./dist",
},
{
from: "./src/assets",
to: "./dist/",
},
],
platform: "browser",
dts: true,
exports: false,
deps: {
neverBundle: [
/\.mp3$/,
/\.wav$/,
/^react$/,
/^react\//,
/^react-dom$/,
/^react-dom\//,
],
},
plugins: [],
});