Input
Text field that types a value at a fixed characters-per-second rate on a "typing" step, independent of the step's own duration, with hover/active/blur/invalid states.
Installation
npx shadcn add https://www.reelcn.dev/r/input.jsonFiles land in src/reelcn/ with the items it builds on. Dependencies: remotion.
Usage
<Center>
<Input
id="email"
label="Work email"
placeholder="you@company.com"
steps={[{ at: 0, state: "idle" }, { at: 0.6, state: "active" }, { at: 0.8, state: "typing", type: "ada@hexhaus.dev" }]}
/>
</Center>Props
PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0.idstring
labelstring
stepsStep<InputState>[]
valuestringStatic value shown outside a "typing" step (e.g. what's left after typing finishes, or a pre-filled
blur/invalid demo).placeholderstring
placePlace
styleCSSProperties
classNamestring
Built from
Use
- A sign-up or checkout field a cursor clicks into and types
- A `ui` scene's `component: "input"`
Avoid
- A field that never receives focus. Use plain text
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/input.tsx
/**
* @title Input
* @category product
* @description Text field that types a value at a fixed characters-per-second rate on a "typing" step, independent of the step's own duration, with hover/active/blur/invalid states.
* @duration data-driven
* @use A sign-up or checkout field a cursor clicks into and types
* @use A `ui` scene's `component: "input"`
* @avoid A field that never receives focus. Use plain text
* @tags input, field, form, typing, ui, kit
* @example
* <Center>
* <Input
* id="email"
* label="Work email"
* placeholder="you@company.com"
* steps={[{ at: 0, state: "idle" }, { at: 0.6, state: "active" }, { at: 0.8, state: "typing", type: "ada@hexhaus.dev" }]}
* />
* </Center>
*/
import type React from "react";
import { useCurrentFrame, useVideoConfig } from "remotion";
import {
type AnchorRect,
anchorId,
type MotionProps,
type Place,
type Step,
useKeyframeState,
useMotion,
useTextMetrics,
useTheme,
useTypedText,
useViewport,
} from "./core";
export type InputState = "idle" | "hover" | "active" | "typing" | "blur" | "invalid";
export type InputProps = MotionProps & {
id?: string;
label?: string;
steps?: Step<InputState>[];
/** Static value shown outside a "typing" step (e.g. what's left after typing finishes, or a pre-filled `blur`/`invalid` demo). */
value?: string;
placeholder?: string;
place?: Place;
style?: React.CSSProperties;
className?: string;
};
export const FIELD_WIDTH = 340;
export const FIELD_HEIGHT = 52;
const PAD_X = 16;
const FONT_SIZE = 20;
/** Characters per second the field types at, fixed, independent of the step's own `at` spacing (the bug
* this row fixes: remocn ties typing speed to transition length instead of a real CPS). */
const INPUT_CPS = 14;
/** The most recent `steps` entry at or before `frame` that carries a `type` string, and the frame it started. */
function typingStepAt<S>(
steps: Step<S>[] | undefined,
frame: number,
fps: number,
): { text: string; startFrame: number } | null {
if (!steps) return null;
let found: { text: string; startFrame: number } | null = null;
for (const step of steps) {
const stepFrame = Math.round(step.at * fps);
if (stepFrame <= frame && step.type !== undefined) found = { text: step.type, startFrame: stepFrame };
}
return found;
}
/** The field's displayed text: live-typed from the applicable step, or the static `value`. Evaluated
* identically by `Input` and `useInputAnchors`, same inputs, same formula, computed twice. */
function useDisplayedValue(
steps: Step<InputState>[] | undefined,
value: string | undefined,
frame: number,
fps: number,
): string {
const typing = typingStepAt(steps, frame, fps);
// useTypedText is pure (no internal hook calls), so calling it conditionally here isn't a Rules-of-Hooks issue.
if (!typing) return value ?? "";
return useTypedText(typing.text, frame - typing.startFrame, fps, { cps: INPUT_CPS }).visible;
}
/** Pure: fixed field proportions, the overall box never depends on the displayed text (only the
* `.caret` sub-anchor would, and the `ui` scene never needs sub-anchors; see Deviation 4a). Shared by
* `useInputAnchors` and the `ui` scene (Task 9), ponytail-review blocker 2's "one formula" contract. */
export function inputBoxSize(u: (n: number) => number) {
return { width: u(FIELD_WIDTH), height: u(FIELD_HEIGHT) };
}
export function useInputAnchors(props: InputProps): Record<string, AnchorRect> {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const theme = useTheme();
const { u, width, height } = useViewport();
const displayed = useDisplayedValue(props.steps, props.value, frame, fps);
const metrics = useTextMetrics(
displayed,
{ fontFamily: theme.fonts.mono, fontSize: u(FONT_SIZE), fontWeight: 500 },
{ skip: !props.id || !props.place },
);
if (!props.id || !props.place) return {};
const box = inputBoxSize(u);
const wPct = (box.width / width) * 100;
const hPct = (box.height / height) * 100;
const fieldX = props.place.x - wPct / 2;
const fieldY = props.place.y - hPct / 2;
const caretXPct = fieldX + ((u(PAD_X) + metrics.width) / width) * 100;
const caretHPct = (u(FIELD_HEIGHT * 0.5) / height) * 100;
const caretWPct = (2 / width) * 100;
return {
[props.id]: { x: fieldX, y: fieldY, width: wPct, height: hPct },
[anchorId(props.id, "caret")]: {
x: caretXPct,
y: props.place.y - caretHPct / 2,
width: caretWPct,
height: caretHPct,
},
};
}
export function Input({ id: _id, label, steps, value, placeholder, place, style, className, ...motion }: InputProps) {
const theme = useTheme();
const { u } = useViewport();
const m = useMotion(motion);
const { state } = useKeyframeState<InputState>(steps, "idle");
const displayed = useDisplayedValue(steps, value, m.frame, m.fps);
const showCaret = state === "typing" || state === "active";
const isInvalid = state === "invalid";
const isFocused = state === "active" || state === "typing";
const borderColor = isInvalid ? theme.colors.danger : isFocused ? theme.colors.accent : theme.colors.border;
const shownText = displayed || placeholder || "";
// T1: a long typed value (past ~30 chars) ran past the field's right edge onto the background, the
// field's own box must stay `FIELD_WIDTH`/`FIELD_HEIGHT` throughout (the ai-generation template flips
// this exact box into a skeleton), so the text shrinks to fit instead, the same "measure, then scale
// the font" approach `CodeBlock`/`Button` already use for their own fixed-box text.
const baseFontPx = u(FONT_SIZE);
const metrics = useTextMetrics(shownText, { fontFamily: theme.fonts.mono, fontSize: baseFontPx, fontWeight: 500 });
const caretAllowance = showCaret ? u(4) : 0;
const availableTextWidth = u(FIELD_WIDTH) - u(PAD_X) * 2 - caretAllowance;
const fontPx =
metrics.ready && metrics.width > availableTextWidth && metrics.width > 0
? baseFontPx * (availableTextWidth / metrics.width)
: baseFontPx;
return (
<div
className={className}
style={{
position: place ? "absolute" : "relative",
left: place ? `${place.x}%` : undefined,
top: place ? `${place.y}%` : undefined,
translate: place ? "-50% -50%" : undefined,
display: "inline-flex",
flexDirection: "column",
gap: u(6),
opacity: m.presence,
...style,
}}
>
{label ? (
<span style={{ color: theme.colors.muted, fontFamily: theme.fonts.body, fontSize: u(15) }}>{label}</span>
) : null}
<div
style={{
width: u(FIELD_WIDTH),
height: u(FIELD_HEIGHT),
border: `${u(1.5)}px solid ${borderColor}`,
borderRadius: u(theme.radius),
display: "flex",
alignItems: "center",
padding: `0 ${u(PAD_X)}px`,
background: theme.colors.surface,
overflow: "hidden",
}}
>
<span
style={{
fontFamily: theme.fonts.mono,
fontSize: fontPx,
color: displayed ? theme.colors.foreground : theme.colors.muted,
whiteSpace: "nowrap",
}}
>
{shownText}
</span>
{showCaret ? (
<span
style={{ width: u(2), height: u(FIELD_HEIGHT * 0.5), background: theme.colors.accent, marginLeft: u(2) }}
/>
) : null}
</div>
</div>
);
}