Assist_Design/docs/plans/2026-03-06-otp-input-redesign-plan.md
barsa 1610e436a5 feat: integrate input-otp library and enhance OTP input handling
- Added input-otp library to streamline OTP input functionality.
- Refactored OtpInput component to utilize InputOTP for improved user experience and mobile SMS autofill.
- Enhanced LoginOtpStep and VerificationStep components to handle OTP input errors and clear states effectively.
- Updated global styles to include animations for OTP caret, improving visual feedback during input.
- Made minor adjustments to LoginForm and OtpStep components for better error handling and user interaction.
2026-03-06 18:56:16 +09:00

8.0 KiB

OTP Input Redesign Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace the custom OtpInput component with shadcn's InputOTP backed by input-otp, keeping the same external API so consumers need zero changes.

Architecture: Install input-otp → generate shadcn input-otp.tsx primitives → rewrite OtpInput molecule internals using those primitives. All 3 consumers (LoginOtpStep, OtpStep, VerificationStep) continue using the same <OtpInput> props interface unchanged.

Tech Stack: input-otp library, shadcn/ui primitives, Tailwind v4, React 19


Task 1: Install input-otp dependency

Files:

  • Modify: apps/portal/package.json

Step 1: Install the package

Run:

pnpm --filter @customer-portal/portal add input-otp

Step 2: Verify installation

Run:

pnpm --filter @customer-portal/portal exec -- node -e "require('input-otp')" 2>&1 || echo "ESM module, checking package exists" && ls node_modules/input-otp/package.json

Expected: Package exists in node_modules.

Step 3: Commit

git add apps/portal/package.json pnpm-lock.yaml
git commit -m "chore: add input-otp dependency"

Task 2: Create shadcn InputOTP primitives

Files:

  • Create: apps/portal/src/components/ui/input-otp.tsx

The shadcn CLI may not work cleanly because components.json aliases utils to @/lib/utils which doesn't exist. Create the component manually based on shadcn's new-york input-otp source, using the project's @/shared/utils import for cn.

Step 1: Create the primitives file

Create apps/portal/src/components/ui/input-otp.tsx:

"use client";

import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "@/shared/utils";

function InputOTP({
  className,
  containerClassName,
  ...props
}: React.ComponentProps<typeof OTPInput> & { containerClassName?: string }) {
  return (
    <OTPInput
      data-slot="input-otp"
      containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
      className={cn("disabled:cursor-not-allowed", className)}
      {...props}
    />
  );
}

function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />
  );
}

function InputOTPSlot({
  index,
  className,
  ...props
}: React.ComponentProps<"div"> & { index: number }) {
  const inputOTPContext = React.useContext(OTPInputContext);
  const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];

  return (
    <div
      data-slot="input-otp-slot"
      data-active={isActive}
      className={cn(
        "relative flex h-14 w-12 items-center justify-center border-y border-r border-border text-xl font-semibold shadow-xs transition-all",
        "first:rounded-l-lg first:border-l last:rounded-r-lg",
        "bg-card text-foreground",
        isActive && "z-10 ring-2 ring-primary border-primary",
        className
      )}
      {...props}
    >
      {char}
      {hasFakeCaret && (
        <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
          <div className="animate-caret-blink h-6 w-px bg-foreground duration-1000" />
        </div>
      )}
    </div>
  );
}

function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
  return (
    <div data-slot="input-otp-separator" role="separator" {...props}>
      <MinusIcon />
    </div>
  );
}

export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

Step 2: Add caret-blink animation to globals.css

Add inside the @theme block in apps/portal/src/app/globals.css, before the closing }:

/* OTP caret blink animation */
--animate-caret-blink: caret-blink 1.2s ease-out infinite;

@keyframes caret-blink {
  0%,
  70%,
  100% {
    opacity: 1;
  }
  20%,
  50% {
    opacity: 0;
  }
}

Step 3: Verify type-check

Run:

pnpm type-check

Expected: PASS

Step 4: Commit

git add apps/portal/src/components/ui/input-otp.tsx apps/portal/src/app/globals.css
git commit -m "feat: add shadcn InputOTP primitives with caret-blink animation"

Task 3: Rewrite OtpInput molecule

Files:

  • Rewrite: apps/portal/src/components/molecules/OtpInput/OtpInput.tsx

Step 1: Replace the entire file

Replace apps/portal/src/components/molecules/OtpInput/OtpInput.tsx with:

/**
 * OtpInput - 6-digit OTP code input
 *
 * Wrapper around shadcn InputOTP (backed by input-otp library).
 * Provides mobile SMS autofill, paste support, and accessible slot rendering.
 */

"use client";

import { REGEXP_ONLY_DIGITS } from "input-otp";
import { cn } from "@/shared/utils";
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
  InputOTPSeparator,
} from "@/components/ui/input-otp";

interface OtpInputProps {
  length?: number | undefined;
  value: string;
  onChange: (value: string) => void;
  onComplete?: ((value: string) => void) | undefined;
  disabled?: boolean | undefined;
  error?: string | undefined;
  autoFocus?: boolean | undefined;
}

export function OtpInput({
  length = 6,
  value,
  onChange,
  onComplete,
  disabled = false,
  error,
  autoFocus = true,
}: OtpInputProps) {
  const firstGroupSize = Math.ceil(length / 2);
  const secondGroupSize = length - firstGroupSize;

  return (
    <div className="space-y-2">
      <div className="flex justify-center">
        <InputOTP
          maxLength={length}
          value={value}
          onChange={onChange}
          onComplete={onComplete}
          disabled={disabled}
          autoFocus={autoFocus}
          pattern={REGEXP_ONLY_DIGITS}
          containerClassName="justify-center"
        >
          <InputOTPGroup className={cn(error && "[&_[data-slot=input-otp-slot]]:border-danger")}>
            {Array.from({ length: firstGroupSize }, (_, i) => (
              <InputOTPSlot key={i} index={i} className={cn(error && "border-danger")} />
            ))}
          </InputOTPGroup>

          {secondGroupSize > 0 && (
            <>
              <InputOTPSeparator />
              <InputOTPGroup
                className={cn(error && "[&_[data-slot=input-otp-slot]]:border-danger")}
              >
                {Array.from({ length: secondGroupSize }, (_, i) => (
                  <InputOTPSlot
                    key={firstGroupSize + i}
                    index={firstGroupSize + i}
                    className={cn(error && "border-danger")}
                  />
                ))}
              </InputOTPGroup>
            </>
          )}
        </InputOTP>
      </div>
      {error && (
        <p className="text-sm text-danger text-center" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

Note: The index.ts barrel file (export { OtpInput } from "./OtpInput") stays unchanged.

Step 2: Verify type-check

Run:

pnpm type-check

Expected: PASS

Step 3: Verify lint

Run:

pnpm lint

Expected: PASS (no unused imports, no deep path violations)

Step 4: Commit

git add apps/portal/src/components/molecules/OtpInput/OtpInput.tsx
git commit -m "refactor: replace custom OtpInput with shadcn InputOTP"

Task 4: Verify all consumers still compile and work

Files: (read-only verification, no changes expected)

  • apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx
  • apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx
  • apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx

Step 1: Full type-check

Run:

pnpm type-check

Expected: PASS — all 3 consumers use the same OtpInputProps API which hasn't changed.

Step 2: Run portal tests

Run:

pnpm --filter @customer-portal/portal test 2>&1 | tail -20

Expected: All tests pass.

Step 3: Final commit if any fixes were needed

Only commit if adjustments were required. Otherwise, skip.