snapcn
libsnap-cn-uisource: ok · refreshed
snapcn UI Core
Shared timeline-fold hook, theme context, and color math for snapcn UI primitives.
npx shadcn add "https://snapcn.dev/r/snap-cn-ui.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightremotionculori@remotion/google-fonts
Files8 · source cached 10/8/2026, 3:32:33 AM
registry/snap-cn-ui/core/timeline.ts
// snapcn UI Core · snapcn — https://snapcn.dev/docs/components
import { useCurrentFrame, useVideoConfig } from "remotion";
import type { Step } from "./types";
export function framesFor(
d: number | { seconds: number },
fps: number,
): number {
return typeof d === "number" ? d : Math.round(d.seconds * fps);
}
export function revealCount(
localFrame: number,
fps: number,
len: number,
cps: number,
): number {
const over = (len / cps) * fps;
if (over <= 0) return len;
return Math.max(0, Math.min(len, Math.floor((localFrame / over) * len)));
}
export function clamp01(t: number): number {
return Math.max(0, Math.min(1, t));
}
export function revealedText(full: string, count: number): string {
const c = Math.max(0, Math.min(full.length, Math.floor(count)));
return full.slice(0, c);
}
export interface TypewriterOptions {
cps?: number;
speed?: number;
startFrame?: number;
}
export interface TypewriterState {
text: string;
count: number;
done: boolean;
typing: boolean;
}
export function useTypewriter(
full: string,
options: TypewriterOptions = {},
): TypewriterState {
const { cps = 20, speed = 1, startFrame = 0 } = options;
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const local = frame * speed - startFrame;
const count = local <= 0 ? 0 : revealCount(local, fps, full.length, cps);
return {
text: revealedText(full, count),
count,
done: count >= full.length,
typing: count > 0 && count < full.length,
};
}
export function useCurrentState<S extends string>(
steps: Step<S>[],
defaultState: S,
speed = 1,
): S {
const effectiveFrame = useCurrentFrame() * speed;
let current = defaultState;
let bestAt = -Infinity;
steps.forEach((step) => {
if (step.at <= effectiveFrame && step.at >= bestAt) {
bestAt = step.at;
current = step.state;
}
});
return current;
}
export function useStateTransition<S extends string>(
steps: Step<S>[],
defaultState: S,
speed = 1,
defaultDuration = 8,
): { from: S; to: S; progress: number } {
const effectiveFrame = useCurrentFrame() * speed;
const started = steps
.map((step, index) => ({ step, index }))
.sort((a, b) => a.step.at - b.step.at || a.index - b.index)
.filter((e) => e.step.at <= effectiveFrame)
// Same-`at` ties: the later array entry wins and the earlier tied
// entries never display, so they can never act as a `from` state.
.filter(
(e, i, arr) => i === arr.length - 1 || arr[i + 1].step.at !== e.step.at,
);
if (started.length === 0)
return { from: defaultState, to: defaultState, progress: 1 };
const to = started[started.length - 1].step;
const from = started.length >= 2 ? started[started.length - 2].step : null;
const dur = to.duration ?? defaultDuration;
const progress = dur > 0 ? clamp01((effectiveFrame - to.at) / dur) : 1;
return { from: from ? from.state : defaultState, to: to.state, progress };
}
registry/snap-cn-ui/core/theme.ts
// snapcn UI Core · snapcn — https://snapcn.dev/docs/components
"use client";
import type { ReactNode } from "react";
import { createContext, createElement, useContext } from "react";
export interface SnapCnTheme {
background: string;
foreground: string;
card: string;
cardForeground: string;
popover: string;
popoverForeground: string;
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
destructiveForeground: string;
border: string;
input: string;
ring: string;
radius: number;
/**
* The face every text component paints with, as a label from `fonts.ts` or a
* CSS family the caller has loaded themselves.
*
* Optional and defaulted to nothing on purpose: unset, each component keeps
* the face it was designed around, so adding this token changed no existing
* render. Set it on `SnapCnUIProvider` and one value re-skins a whole
* timeline — which is the only reason it lives on the theme rather than
* staying a per-component prop.
*/
fontFamily?: string;
}
/**
* The shadcn token set, as concrete values.
*
* ## These are a mirror of `app/globals.css`, and that is the whole point
*
* A component we ship lands next to somebody's `Button` and `Input`, and those
* paint from the CSS custom properties in `globals.css`. A scene that paints
* from a *different* set of greys is a scene that clashes with the app it was
* installed into — which is exactly what happened here: this file was written
* first, `globals.css` was later re-skinned warm, and for a while the site and
* the videos it sells were two different products.
*
* So every value below is the **literal string** from the corresponding
* `--token` in `app/globals.css` (`:root` for light, `.dark` for dark). Hex, not
* oklch, purely so that drift is a string comparison — if the two files ever
* disagree again, `pnpm run check:tokens` says so and prints the pairs.
*
* ## Why not just read `var(--token)`?
*
* Because a Remotion bundle has none of the app's CSS, so `var()` resolves to
* nothing under the headless renderer and cannot be interpolated by `mixOklch`
* at all (see the guard in `color.ts`). Concrete values are not a shortcut here,
* they are the only thing that survives a render. Users on a different palette
* override via `SnapCnUIProvider` or a component's `theme` prop.
*
* ## `radius` is the one value that flows the other way
*
* `globals.css` sets `--radius: 0.28rem` *so that* its `--radius-3xl` step lands
* on this 10 — see the note beside it there. It is not drift; do not "fix" it.
*/
export const defaultLightTheme: SnapCnTheme = {
background: "#faf9f6", // page — warm off-white
foreground: "#141414", // text
card: "#ffffff", // surface on the page
cardForeground: "#141414",
popover: "#ffffff",
popoverForeground: "#141414",
primary: "#3072db", // accent blue
primaryForeground: "#ffffff",
secondary: "#f2f0eb", // subtle fill
secondaryForeground: "#141414",
muted: "#f2f0eb",
mutedForeground: "#6e6a63", // secondary text, leading icons
accent: "#f2f0eb", // hover wash
accentForeground: "#141414",
destructive: "#d92d20",
// `globals.css` defines no --destructive-foreground; white is the only thing
// that reads on #d92d20, and shadcn's own default agrees.
destructiveForeground: "#ffffff",
border: "#d9d9d9", // hairline
input: "#d9d9d9",
ring: "#3072db",
radius: 10,
};
export const defaultDarkTheme: SnapCnTheme = {
background: "#0a0a0b", // page — near-black
foreground: "#fafafa", // text
card: "#141417", // surface, a shade above the page
cardForeground: "#fafafa",
popover: "#141417",
popoverForeground: "#fafafa",
primary: "#3072db", // the same accent blue in both modes
primaryForeground: "#ffffff",
secondary: "#1d1d21", // subtle fill
secondaryForeground: "#fafafa",
muted: "#1b1b1f",
mutedForeground: "#a1a1aa", // secondary text, leading icons
accent: "#1d1d21", // hover wash
accentForeground: "#fafafa",
destructive: "#f97066",
destructiveForeground: "#fafafa",
border: "#26272b", // hairline — separation is borders, not shadows
input: "#26272b",
ring: "#3072db",
radius: 10,
};
interface SnapCnThemeContextValue {
theme?: Partial<SnapCnTheme>;
mode?: "light" | "dark";
}
const SnapCnThemeContext = createContext<SnapCnThemeContextValue>({});
export interface SnapCnUIProviderProps {
theme?: Partial<SnapCnTheme>;
mode?: "light" | "dark";
children: ReactNode;
}
export function SnapCnUIProvider({
theme,
mode,
children,
}: SnapCnUIProviderProps) {
return createElement(
SnapCnThemeContext.Provider,
{ value: { theme, mode } },
children,
);
}
export function useSnapCnTheme(
override?: Partial<SnapCnTheme>,
modeOverride?: "light" | "dark",
): SnapCnTheme {
const ctx = useContext(SnapCnThemeContext);
const mode = modeOverride ?? ctx.mode ?? "light";
const base = mode === "dark" ? defaultDarkTheme : defaultLightTheme;
return { ...base, ...ctx.theme, ...override };
}
registry/snap-cn-ui/core/fonts.ts
// snapcn UI Core · snapcn — https://snapcn.dev/docs/components
"use client";
import { loadFont as loadGeist } from "@remotion/google-fonts/Geist";
import { loadFont as loadInstrumentSerif } from "@remotion/google-fonts/InstrumentSerif";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { loadFont as loadMontserrat } from "@remotion/google-fonts/Montserrat";
import { loadFont as loadOutfit } from "@remotion/google-fonts/Outfit";
import { loadFont as loadSpaceGrotesk } from "@remotion/google-fonts/SpaceGrotesk";
/**
* The type faces a scene's `fontFamily` may name.
*
* ## Why an allowlist and not a free string
*
* Every component in this registry loads its face through
* `@remotion/google-fonts` rather than a CSS variable, for the reason spelled
* out at the top of each one: a Remotion bundle has none of the app's CSS, so
* `var(--font-…)` gets you the right face in the Player and a **fallback in the
* mp4**. A bare family name handed to a component has exactly the same problem
* — the browser preview finds it, the render does not, and the bug is invisible
* until someone plays the file.
*
* So a name only counts as a font here if this module has loaded it. Values map
* label → the real CSS family string `loadFont` returns, which is not always the
* label ("Instrument Serif", not "InstrumentSerif").
*
* ## A raw stack still works
*
* `resolveFont` passes anything it does not recognise straight through, so a
* caller who has loaded their own face — a brand font, a local `@font-face` —
* names it directly and gets it. The allowlist is the safe path, not the only one.
*
* ponytail: all six load at module scope, so a render fetches six latin subsets
* whether or not the video uses them. Move to a lazy per-family `loadFont()` if
* bundle time ever shows up in a render trace.
*/
export const FONTS: Record<string, string> = {
Inter: loadInter("normal", {
weights: ["400", "500", "600", "700"],
subsets: ["latin"],
}).fontFamily,
Geist: loadGeist("normal", {
weights: ["400", "500", "600", "700"],
subsets: ["latin"],
}).fontFamily,
"Space Grotesk": loadSpaceGrotesk("normal", {
weights: ["400", "500", "600", "700"],
subsets: ["latin"],
}).fontFamily,
Outfit: loadOutfit("normal", {
weights: ["400", "500", "600", "700"],
subsets: ["latin"],
}).fontFamily,
Montserrat: loadMontserrat("normal", {
weights: ["400", "500", "600", "700"],
subsets: ["latin"],
}).fontFamily,
// 400 is the only weight this family ships. Asking for 600 throws.
"Instrument Serif": loadInstrumentSerif("normal", {
weights: ["400"],
subsets: ["latin"],
}).fontFamily,
};
/**
* What a select control offers. `DEFAULT_FONT` first: a scene's own face is a
* design decision, so the knob has to have an "off", and it has to be the
* value it ships at.
*/
export const DEFAULT_FONT = "Default";
export const FONT_NAMES = [DEFAULT_FONT, ...Object.keys(FONTS)];
/**
* Label → loaded CSS family. Anything unrecognised passes through unchanged, so
* a caller's own stack is never swallowed. `undefined` in, `undefined` out —
* that is the signal for "keep the component's own face".
*/
export const resolveFont = (name?: string): string | undefined =>
!name || name === DEFAULT_FONT ? undefined : (FONTS[name] ?? name);
registry/snap-cn-ui/core/color.ts
// snapcn UI Core · snapcn — https://snapcn.dev/docs/components
import {
clampChroma,
converter,
formatRgb,
interpolate,
type Oklch,
parse,
type Rgb,
} from "culori";
const toRgb = converter("rgb");
const toOklch = converter("oklch");
const BLACK: Rgb = { mode: "rgb", r: 0, g: 0, b: 0, alpha: 1 };
/**
* Whether to warn. Read through `globalThis`, never a bare `process`: a Vite
* project defines no `process`, so the bare name threw a ReferenceError on the
* very input it meant to warn about — and a stock Remotion project has no Node
* types, so the bare name was also a type error in every install.
*/
function isDev(): boolean {
const env = (globalThis as { process?: { env?: { NODE_ENV?: string } } })
.process?.env;
return env?.NODE_ENV !== "production";
}
export function parseColor(c: string): Rgb {
const s = c.trim();
if (s.startsWith("var(")) {
if (isDev()) {
console.warn(
`[snap-cn-ui] parseColor cannot resolve CSS variable "${s}" under Remotion's per-frame render. ` +
"Animated colors must be concrete oklch/hex/rgb values supplied via the theme. " +
"Falling back to the JS default.",
);
}
return { ...BLACK };
}
const rgb = toRgb(parse(s));
if (!rgb) {
if (isDev()) {
console.warn(
`[snap-cn-ui] parseColor could not parse "${s}"; using black.`,
);
}
return { ...BLACK };
}
return { mode: "rgb", r: rgb.r, g: rgb.g, b: rgb.b, alpha: rgb.alpha ?? 1 };
}
export function oklchToRgb(l: number, c: number, h: number): Rgb {
const mapped = clampChroma({ mode: "oklch", l, c, h }, "oklch", "rgb");
return toRgb(mapped);
}
export function rgbToOklch(rgb: Rgb): Oklch {
const { l, c, h } = toOklch(rgb);
return { mode: "oklch", l, c, h: Number.isFinite(h) ? h : 0 };
}
function resolveColorString(s: string): string {
const trimmed = s.trim();
if (trimmed.startsWith("var(")) {
if (isDev()) {
console.warn(
`[snap-cn-ui] mixOklch cannot resolve CSS variable "${trimmed}" under Remotion's per-frame render. ` +
"Animated colors must be concrete oklch/hex/rgb values supplied via the theme. " +
"Falling back to the JS default.",
);
}
return "#000";
}
if (!parse(trimmed)) {
if (isDev()) {
console.warn(
`[snap-cn-ui] mixOklch could not parse "${trimmed}"; using black.`,
);
}
return "#000";
}
return trimmed;
}
export function mixOklch(a: string, b: string, t: number): string {
const mixed = clampChroma(
interpolate([resolveColorString(a), resolveColorString(b)], "oklch")(t),
"oklch",
"rgb",
);
return toCss(toRgb(mixed));
}
export function toCss(color: Rgb): string {
return formatRgb(color);
}
/**
* A token at partial opacity — for scrims, veils and inner washes.
*
* A scrim written as a literal `rgba(0,0,0,0.4)` is lit for a light surface and
* does nothing on a dark one; `withAlpha(t.foreground, 0.4)` follows the theme
* and a user's override. Resolves through `parseColor`, so it inherits the same
* `var()` guard — an unresolvable token degrades to black rather than an
* invalid colour string mid-render.
*/
export function withAlpha(color: string, alpha: number): string {
const { r, g, b } = parseColor(color);
return formatRgb({ mode: "rgb", r, g, b, alpha });
}
registry/snap-cn-ui/core/motion.ts
// snapcn UI Core · snapcn — https://snapcn.dev/docs/components
export const easings = {
linear: (t: number): number => t,
out: (t: number): number => 1 - (1 - t) ** 3,
in: (t: number): number => t * t * t,
inOut: (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2,
} as const;
export type EasingName = keyof typeof easings;
export const springs = {
snappy: { damping: 18, stiffness: 220, mass: 0.7 },
soft: { damping: 14, stiffness: 120, mass: 0.9 },
bouncy: { damping: 10, stiffness: 180, mass: 0.8 },
} as const;
export type SpringName = keyof typeof springs;
registry/snap-cn-ui/core/item.tsx
// snapcn UI Core · snapcn — https://snapcn.dev/docs/components
import {
createContext,
type ReactElement,
type ReactNode,
useContext,
} from "react";
/**
* One object in a scene — a card, a step, a message, a photo — handed to
* whoever is editing the scene.
*
* A scene draws its objects itself: it knows where each one is on every frame.
* Remotion Studio wants each one as something it can select, outline and edit
* on its own. `Item` is the seam between the two. The scene wraps the root
* element of each object in it:
*
* <Item key={i} index={k}>
* <div style={…}>…</div>
* </Item>
*
* With nothing above it — which is every `shadcn add` install and every render
* on the site — it returns that element untouched: no wrapper, no extra DOM, no
* behaviour. Inside a Studio Element, an `ItemHost` above it gets the element
* and the object's index, and can put it inside the object's own `<Sequence>`
* with the selection outline on that exact node, so the outline moves with the
* object for its whole animation.
*/
export type ItemHost = (index: number, node: ReactElement) => ReactNode;
const ItemHostContext = createContext<ItemHost | null>(null);
export const ItemHostProvider = ItemHostContext.Provider;
export function Item({
index,
primary = true,
children,
}: {
/** Which of the scene's objects this is, in the order its props list them. */
index: number;
/**
* False on every copy but one, for a scene that draws the same object more
* than once (a looping rail): an object has one outline.
*/
primary?: boolean;
children: ReactElement;
}): ReactNode {
const host = useContext(ItemHostContext);
return host && primary ? host(index, children) : children;
}
registry/snap-cn-ui/core/types.ts
// snapcn UI Core · snapcn — https://snapcn.dev/docs/components
export interface Step<S extends string = string> {
at: number;
state: S;
duration?: number;
}
registry/snap-cn-ui/core/index.ts
// snapcn UI Core · snapcn — https://snapcn.dev/docs/components
export {
mixOklch,
oklchToRgb,
parseColor,
rgbToOklch,
toCss,
withAlpha,
} from "./color";
export { FONT_NAMES, FONTS, resolveFont } from "./fonts";
export type { ItemHost } from "./item";
export { Item, ItemHostProvider } from "./item";
export type { EasingName, SpringName } from "./motion";
export { easings, springs } from "./motion";
export type { SnapCnTheme, SnapCnUIProviderProps } from "./theme";
export {
defaultDarkTheme,
defaultLightTheme,
SnapCnUIProvider,
useSnapCnTheme,
} from "./theme";
export type { TypewriterOptions, TypewriterState } from "./timeline";
export {
clamp01,
framesFor,
revealCount,
revealedText,
useCurrentState,
useStateTransition,
useTypewriter,
} from "./timeline";
export type { Step } from "./types";