NIMA MZ

Lookout

Component 84

About

Expressive SVG eyes for any form: they follow the pointer, read along with your caret in the field you're typing in, look away when it's a password, peek when you press show-password, and react to errors and idle time. One file, no images, theme tokens.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Lookout to your project and fits it to your theme.

Code

How Lookout is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client"

import * as React from "react"
// Relative import on purpose: the 21st CLI rewrites it for publish and render.
import { Lookout, type LookoutMood } from "@/components/ui/lookout"

// Module-level settings + a default export that takes them as props = live controls on 21st.dev.
// Signature knobs first, then the character, then copy.
const settings = {
  lookAwayForPasswords: true,
  followPointer: true,
  startLookingAway: true,
  idleAfter: 8,
  blink: true,
  squareHead: false,
  size: 176,
  useIrisColor: true,
  irisColor: "#6366f1",
  headline: "Welcome back",
}

export default function Demo(props: Partial<typeof settings>) {
  const s = { ...settings, ...props }
  const formRef = React.useRef<HTMLFormElement>(null)
  const [reveal, setReveal] = React.useState(false)
  // The first frame shows the eyes politely looking away from a filled password field; any
  // interaction hands control back to the form ("auto").
  const [mood, setMood] = React.useState<LookoutMood>(s.startLookingAway ? "secret" : "auto")
  const [note, setNote] = React.useState<string | null>(null)
  const live = () => setMood((m) => (m === "secret" ? "auto" : m))

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const data = new FormData(e.currentTarget)
    const ok = String(data.get("email")).includes("@") && String(data.get("password")).length >= 8
    setMood(ok ? "happy" : "sad")
    setNote(ok ? "Signed in. Redirecting…" : "Use a valid email and at least 8 characters.")
    window.setTimeout(() => {
      setMood("auto")
      setNote(null)
    }, 1800)
  }

  const field =
    "bg-background border-input placeholder:text-muted-foreground focus-visible:ring-ring/50 h-11 rounded-md border px-3 text-base font-normal outline-none focus-visible:ring-[3px] md:text-sm"

  return (
    <div className="bg-background flex min-h-[max(560px,100svh)] w-full items-center justify-center px-6 py-12">
      <div
        onPointerDownCapture={live}
        onFocusCapture={live}
        className="bg-card text-card-foreground border-border grid w-full max-w-3xl overflow-hidden rounded-2xl border shadow-sm md:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]"
      >
        <div className="bg-muted/50 flex flex-col items-center justify-center gap-6 px-8 py-12">
          <Lookout
            shape={s.squareHead ? "square" : "orb"}
            size={s.size}
            scope={formRef}
            follow={s.followPointer ? "both" : "form"}
            blink={s.blink}
            lookAwayForPasswords={s.lookAwayForPasswords}
            idleAfter={s.idleAfter}
            mood={mood}
            restGaze={[0.45, 0.1]}
            irisColor={s.useIrisColor ? s.irisColor : undefined}
          />
          <p className="text-muted-foreground max-w-[22ch] text-center text-sm text-pretty">
            I read along as you type. I won't look at your password.
          </p>
        </div>

        <form ref={formRef} onSubmit={onSubmit} noValidate className="flex flex-col justify-center gap-5 px-8 py-10">
          <div>
            <h1 className="text-2xl font-semibold tracking-tight">{s.headline}</h1>
            <p className="text-muted-foreground mt-1 text-sm">Sign in to continue to Meridian.</p>
          </div>

          <label className="flex flex-col gap-1.5 text-sm font-medium">
            Email
            {/* type="text" + inputMode keeps the caret API available, so the eyes can read along letter by letter. */}
            <input name="email" type="text" inputMode="email" autoComplete="email" defaultValue="nima@meridian.app" className={field} />
          </label>

          <label className="flex flex-col gap-1.5 text-sm font-medium">
            Password
            <span className="relative flex">
              <input
                name="password"
                type={reveal ? "text" : "password"}
                autoComplete="current-password"
                defaultValue="correct-horse"
                className={`${field} w-full pr-11`}
              />
              <button
                type="button"
                id="show-password"
                data-slot="cta-primary"
                data-lookout="reveal"
                aria-label={reveal ? "Hide password" : "Show password"}
                aria-pressed={reveal}
                // Keep focus in the field so the eyes keep reading (and peek) instead of jumping to the button.
                onMouseDown={(e) => e.preventDefault()}
                onClick={() => setReveal((v) => !v)}
                className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute inset-y-0 right-0 inline-flex w-11 cursor-pointer items-center justify-center rounded-r-md outline-none focus-visible:ring-[3px]"
              >
                {reveal ? <EyeOff /> : <Eye />}
              </button>
            </span>
          </label>

          <div className="flex items-center justify-between text-sm">
            <label className="text-muted-foreground inline-flex items-center gap-2">
              <input type="checkbox" name="remember" className="accent-primary size-4" /> Remember me
            </label>
            <a href="#reset" className="text-muted-foreground hover:text-foreground underline-offset-4 hover:underline">
              Forgot password?
            </a>
          </div>

          <button
            type="submit"
            className="bg-primary text-primary-foreground focus-visible:ring-ring/50 inline-flex h-11 cursor-pointer items-center justify-center rounded-md text-sm font-medium shadow-sm transition-[transform,box-shadow] duration-150 outline-none hover:shadow-md focus-visible:ring-[3px] active:scale-[0.98]"
          >
            Sign in
          </button>

          <p aria-live="polite" className="text-muted-foreground min-h-5 text-sm">
            {note}
          </p>
        </form>
      </div>
    </div>
  )
}

function Eye() {
  return (
    <svg aria-hidden="true" viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
      <circle cx="12" cy="12" r="3" />
    </svg>
  )
}

function EyeOff() {
  return (
    <svg aria-hidden="true" viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M3 3l18 18" />
      <path d="M10.6 5.2A10.9 10.9 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.1" />
      <path d="M6.6 6.6A17.9 17.9 0 0 0 2 12s3.5 7 10 7a10.7 10.7 0 0 0 4.3-.9" />
      <path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
    </svg>
  )
}

More by NIMA MZ

View all
  • Agent Trace

    Agent Trace

    692
  • Sonar Grid

    Sonar Grid

    584
  • Receipt Pricing

    Receipt Pricing

    148
  • Import Patchbay

    Import Patchbay

    124
  • Moiré Field

    Moiré Field

    31
  • Meridian Rail

    Meridian Rail

    2
  • Split Flap Button

    Split Flap Button

    1
  • Ink Marbling

    Ink Marbling