shadimage
snapcn
componentinputsource: ok · refreshed

UI Input

A text input whose idle/hover/active/typing/invalid state is a pure function of the timeline; the focus ring, border, caret, and value reveal are keyframed presets.

npx shadcn add "https://snapcn.dev/r/input.json"
free

Where it goes

stats

Visual style

playful

Interaction

animated-counterscroll-revealtext-animation

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
remotionclass-variance-authority

Registry dependencies

https://snapcn.dev/r/snap-cn-ui.jsonhttps://snapcn.dev/r/caret.jsonutils

Files2 · source cached 10/8/2026, 4:08:10 AM

registry/snap-cn-ui/input/index.tsx
// UI Input · snapcn — https://snapcn.dev/docs/components
"use client";

import { cva } from "class-variance-authority";
import { Caret } from "@/components/snap-cn/caret";
import {
  mixOklch,
  revealedText,
  type SnapCnTheme,
  useSnapCnTheme,
} from "@/lib/snap-cn-ui";
import { cn } from "@/lib/utils";

export type InputState =
  | "idle"
  | "hover"
  | "active"
  | "typing"
  | "blur"
  | "invalid";

type InputSize = "sm" | "default" | "lg";

export interface InputProps {
  state?: InputState;
  style?: InputStyle;
  placeholder?: string;
  value?: string;
  size?: InputSize;
  theme?: Partial<SnapCnTheme>;
  primary?: string;
  fullWidth?: boolean;
  className?: string;
}

/**
 * The control, as classes.
 *
 * This is a shadcn field that happens to be driven by a frame clock, so it is
 * built the way shadcn builds one: `cva` for the size scale, `cn` for merging,
 * `data-slot` for targeting. What stays in `style` is *only* what is tweened —
 * see the note on the render below.
 */
const inputVariants = cva(
  "relative flex items-center border border-solid tracking-[-0.01em]",
  {
    variants: {
      size: {
        sm: "h-9 px-3 text-[13px]",
        default: "h-10 px-3.5 text-[15px]",
        lg: "h-12 px-4 text-[17px]",
      },
      fullWidth: { true: "w-full", false: "w-80" },
    },
    defaultVariants: { size: "default", fullWidth: false },
  },
);

/** The placeholder overlays the value, so it needs the field's own inset. */
const placeholderVariants = cva(
  "pointer-events-none absolute whitespace-nowrap",
  {
    variants: { size: { sm: "left-3", default: "left-3.5", lg: "left-4" } },
    defaultVariants: { size: "default" },
  },
);

/** `Caret` takes a pixel height, not a class — so this one number stays a number. */
const CARET_HEIGHT: Record<InputSize, number> = { sm: 14, default: 17, lg: 19 };

export interface InputStyle {
  borderColor: string;
  ringColor: string;
  ringWidth: number;
  background: string;
  caretOpacity: number;
  valueReveal: number;
  placeholderOpacity: number;
}

export interface InputStyleContext {
  idleBorder: string;
  hoverBorder: string;
  activeBorder: string;
  invalidBorder: string;
  ring: string;
  invalidRing: string;
  background: string;
  hoverBackground: string;
  foreground: string;
  mutedForeground: string;
}

export function inputStyleContext(theme: SnapCnTheme): InputStyleContext {
  return {
    idleBorder: theme.input,
    hoverBorder: mixOklch(theme.input, theme.foreground, 0.18),
    activeBorder: theme.ring,
    invalidBorder: theme.destructive,
    ring: mixOklch(theme.background, theme.ring, 0.5),
    invalidRing: mixOklch(theme.background, theme.destructive, 0.4),
    background: theme.background,
    hoverBackground: mixOklch(theme.background, theme.muted, 0.4),
    foreground: theme.foreground,
    mutedForeground: theme.mutedForeground,
  };
}

export function inputStyle(
  state: InputState,
  ctx: InputStyleContext,
): InputStyle {
  switch (state) {
    case "hover":
      return {
        borderColor: ctx.hoverBorder,
        ringColor: ctx.ring,
        ringWidth: 0,
        background: ctx.hoverBackground,
        caretOpacity: 0,
        valueReveal: 0,
        placeholderOpacity: 1,
      };
    case "active":
      return {
        borderColor: ctx.activeBorder,
        ringColor: ctx.ring,
        ringWidth: 3,
        background: ctx.background,
        caretOpacity: 1,
        valueReveal: 0,
        placeholderOpacity: 1,
      };
    case "typing":
      return {
        borderColor: ctx.activeBorder,
        ringColor: ctx.ring,
        ringWidth: 3,
        background: ctx.background,
        caretOpacity: 1,
        valueReveal: 1,
        placeholderOpacity: 0,
      };
    case "blur":
      return {
        borderColor: ctx.idleBorder,
        ringColor: ctx.ring,
        ringWidth: 0,
        background: ctx.background,
        caretOpacity: 0,
        valueReveal: 1,
        placeholderOpacity: 0,
      };
    case "invalid":
      return {
        borderColor: ctx.invalidBorder,
        ringColor: ctx.invalidRing,
        ringWidth: 3,
        background: ctx.background,
        caretOpacity: 0,
        valueReveal: 1,
        placeholderOpacity: 0,
      };
    default:
      return {
        borderColor: ctx.idleBorder,
        ringColor: ctx.ring,
        ringWidth: 0,
        background: ctx.background,
        caretOpacity: 0,
        valueReveal: 0,
        placeholderOpacity: 1,
      };
  }
}

export function Input({
  state = "idle",
  style,
  placeholder = "you@example.com",
  value = "remotion@snapcn.dev",
  size = "default",
  theme: themeOverride,
  primary,
  fullWidth = false,
  className,
}: InputProps) {
  const theme = useSnapCnTheme({
    ...themeOverride,
    ...(primary ? { primary } : {}),
  });

  const ctx = inputStyleContext(theme);
  const v = style ?? inputStyle(state, ctx);
  const revealed = revealedText(
    value,
    Math.round(value.length * v.valueReveal),
  );

  return (
    // No font-family. A shadcn Input does not set one — it inherits, and the
    // hard-coded `var(--font-geist-sans)` that used to be here resolved on the
    // site and to nothing in a render, which is the bug this tier existed to
    // avoid.
    <div className="absolute inset-0 flex items-center justify-center">
      <div
        data-slot="input"
        data-state={state}
        className={cn(inputVariants({ size, fullWidth }), className)}
        // Everything left in `style` is a value being *tweened between shadcn
        // states* by `use-input-transition` — `mixOklch(idleBorder → ring)`, a
        // ring growing 0→3px. A class is a binary state; this component's whole
        // job is the 300ms in between, so these cannot be classes and are not a
        // compromise. `borderRadius` follows the theme token.
        style={{
          background: v.background,
          borderColor: v.borderColor,
          borderRadius: theme.radius,
          boxShadow: `0 0 0 ${v.ringWidth}px ${v.ringColor}`,
        }}
      >
        <span
          data-slot="input-placeholder"
          className={placeholderVariants({ size })}
          style={{
            color: ctx.mutedForeground,
            opacity: v.valueReveal > 0 ? 0 : v.placeholderOpacity,
          }}
        >
          {placeholder}
        </span>

        <div className="flex min-w-0 items-center">
          <span className="whitespace-nowrap" style={{ color: ctx.foreground }}>
            {revealed}
          </span>
          <Caret
            color={ctx.foreground}
            height={CARET_HEIGHT[size]}
            radius={1}
            opacity={v.caretOpacity}
            marginLeft={revealed.length > 0 ? 4 : 0}
          />
        </div>
      </div>
    </div>
  );
}
registry/snap-cn-ui/input/use-input-transition.ts
// UI Input · snapcn — https://snapcn.dev/docs/components
"use client";

import {
  type InputState,
  type InputStyle,
  inputStyle,
  inputStyleContext,
} from "@/components/snap-cn/input";
import {
  easings,
  mixOklch,
  type SnapCnTheme,
  type Step,
  useSnapCnTheme,
  useStateTransition,
} from "@/lib/snap-cn-ui";

export const DEFAULT_DURATION = 8;

export function tweenInputStyle(
  a: InputStyle,
  b: InputStyle,
  t: number,
): InputStyle {
  return {
    ringWidth: a.ringWidth + (b.ringWidth - a.ringWidth) * t,
    caretOpacity: a.caretOpacity + (b.caretOpacity - a.caretOpacity) * t,
    valueReveal: a.valueReveal + (b.valueReveal - a.valueReveal) * t,
    placeholderOpacity:
      a.placeholderOpacity + (b.placeholderOpacity - a.placeholderOpacity) * t,
    borderColor: mixOklch(a.borderColor, b.borderColor, t),
    ringColor: mixOklch(a.ringColor, b.ringColor, t),
    background: mixOklch(a.background, b.background, t),
  };
}

export interface InputTransitionOptions {
  theme?: Partial<SnapCnTheme>;
  mode?: "light" | "dark";
  primary?: string;
  speed?: number;
  defaultDuration?: number;
}

export function useInputTransition(
  steps: Step<InputState>[],
  opts: InputTransitionOptions = {},
): InputStyle {
  const {
    theme: themeOverride,
    mode,
    primary,
    speed = 1,
    defaultDuration = DEFAULT_DURATION,
  } = opts;
  const theme = useSnapCnTheme(
    { ...themeOverride, ...(primary ? { primary } : {}) },
    mode,
  );
  const ctx = inputStyleContext(theme);
  const { from, to, progress } = useStateTransition(
    steps,
    "idle",
    speed,
    defaultDuration,
  );
  const t = easings.out(progress);
  return tweenInputStyle(inputStyle(from, ctx), inputStyle(to, ctx), t);
}

More from snapcn

Browse all components in this library →