shadimage
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 · light
remotionculori@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";

More from snapcn

Browse all components in this library →