Web InnoventixFreeCode

Floating Newsletter

Original · free

floating newsletter widget

byWeb InnoventixReact + Tailwind
newsxfloatingnewsletter
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/newsx-floating.json
newsx-floating.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Frequency = "weekly" | "monthly";
type Status = "idle" | "error" | "success";

const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const FREQUENCIES: { value: Frequency; label: string; hint: string }[] = [
  { value: "weekly", label: "Weekly", hint: "Every Thursday" },
  { value: "monthly", label: "Monthly", hint: "First of the month" },
];

const RECENT_ISSUES: { no: string; title: string; date: string }[] = [
  { no: "184", title: "Container queries that survived production", date: "Jul 10" },
  { no: "183", title: "Making <dialog> genuinely accessible", date: "Jul 3" },
  { no: "182", title: "The real cost of one extra re-render", date: "Jun 26" },
];

function MailIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-5 w-5">
      <path
        d="M3 7.5A2.5 2.5 0 0 1 5.5 5h13A2.5 2.5 0 0 1 21 7.5v9a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 16.5v-9Z"
        stroke="currentColor"
        strokeWidth="1.6"
      />
      <path d="m4 7.8 8 5.2 8-5.2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function CloseIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <path d="m6 6 12 12M18 6 6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-6 w-6">
      <path d="m5 12.5 4.2 4.2L19 7.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function ArrowIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <path d="M5 12h14m-5-5 5 5-5 5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function SparkIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
      <path
        d="M12 3.5c.4 3.6 1.4 4.6 5 5-3.6.4-4.6 1.4-5 5-.4-3.6-1.4-4.6-5-5 3.6-.4 4.6-1.4 5-5Z"
        fill="currentColor"
      />
    </svg>
  );
}

export default function NewsxFloating() {
  const reduceMotion = useReducedMotion();

  const [open, setOpen] = useState(false);
  const [unread, setUnread] = useState(true);
  const [email, setEmail] = useState("");
  const [frequency, setFrequency] = useState<Frequency>("weekly");
  const [status, setStatus] = useState<Status>("idle");
  const [shake, setShake] = useState(false);

  const uid = useId();
  const panelId = `newsx-panel-${uid}`;
  const titleId = `newsx-title-${uid}`;
  const descId = `newsx-desc-${uid}`;
  const emailId = `newsx-email-${uid}`;
  const errorId = `newsx-error-${uid}`;
  const groupId = `newsx-freq-${uid}`;

  const toggleRef = useRef<HTMLButtonElement | null>(null);
  const emailRef = useRef<HTMLInputElement | null>(null);

  useEffect(() => {
    if (!open) return undefined;
    setUnread(false);
    const id = window.setTimeout(() => emailRef.current?.focus(), reduceMotion ? 0 : 240);
    return () => window.clearTimeout(id);
  }, [open, reduceMotion]);

  const closePanel = () => {
    setOpen(false);
    toggleRef.current?.focus();
  };

  const handlePanelKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "Escape") {
      event.stopPropagation();
      closePanel();
    }
  };

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (!EMAIL_PATTERN.test(email.trim())) {
      setStatus("error");
      setShake(true);
      emailRef.current?.focus();
      return;
    }
    setStatus("success");
  };

  const resetForm = () => {
    setEmail("");
    setStatus("idle");
    const id = window.setTimeout(() => emailRef.current?.focus(), reduceMotion ? 0 : 60);
    return () => window.clearTimeout(id);
  };

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-5 py-16 sm:px-8 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes newsxfloat-bob {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-4px); }
        }
        @keyframes newsxfloat-ping {
          0% { transform: scale(1); opacity: 0.55; }
          80%, 100% { transform: scale(2.4); opacity: 0; }
        }
        @keyframes newsxfloat-shake {
          10%, 90% { transform: translateX(-1px); }
          20%, 80% { transform: translateX(2px); }
          30%, 50%, 70% { transform: translateX(-4px); }
          40%, 60% { transform: translateX(4px); }
        }
        @keyframes newsxfloat-shimmer {
          0% { background-position: -120% 0; }
          100% { background-position: 220% 0; }
        }
        .newsxfloat-bob-anim { animation: newsxfloat-bob 3.6s ease-in-out infinite; }
        .newsxfloat-ping-anim { animation: newsxfloat-ping 1.9s cubic-bezier(0, 0, 0.2, 1) infinite; }
        .newsxfloat-shake-anim { animation: newsxfloat-shake 0.5s ease-in-out; }
        .newsxfloat-shimmer-anim { animation: newsxfloat-shimmer 7s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .newsxfloat-bob-anim,
          .newsxfloat-ping-anim,
          .newsxfloat-shake-anim,
          .newsxfloat-shimmer-anim { animation: none !important; }
        }
      `}</style>

      {/* ambient backdrop */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 [background:radial-gradient(60rem_40rem_at_15%_-10%,rgba(99,102,241,0.10),transparent),radial-gradient(45rem_30rem_at_110%_120%,rgba(139,92,246,0.10),transparent)]"
      />

      <div className="relative mx-auto flex min-h-[34rem] max-w-5xl flex-col">
        {/* page context so the widget reads as floating over real content */}
        <div className="max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200/70 bg-white/70 px-3 py-1 text-xs font-medium text-indigo-700 backdrop-blur dark:border-indigo-400/20 dark:bg-slate-900/60 dark:text-indigo-300">
            <SparkIcon />
            An independent frontend letter
          </span>
          <h2 className="mt-5 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-slate-50">
            Signal Weekly
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 sm:text-lg dark:text-slate-400">
            One email every Thursday: the three frontend techniques worth your time this week, plus a short teardown of
            something real that shipped. No hot takes, no reprinted release notes.
          </p>
        </div>

        <div className="mt-10 grid gap-3 sm:mt-12 sm:grid-cols-3">
          {RECENT_ISSUES.map((issue) => (
            <article
              key={issue.no}
              className="rounded-2xl border border-slate-200 bg-white/80 p-4 backdrop-blur transition-colors hover:border-indigo-300 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:border-indigo-500/40"
            >
              <div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-500">
                <span className="font-mono font-medium text-indigo-600 dark:text-indigo-400">#{issue.no}</span>
                <time>{issue.date}</time>
              </div>
              <p className="mt-2 text-sm font-medium leading-snug text-slate-800 dark:text-slate-200">{issue.title}</p>
            </article>
          ))}
        </div>

        <p className="mt-8 text-sm text-slate-500 dark:text-slate-500">
          Read by 4,200+ engineers at independent studios and product teams.
        </p>

        {/* floating widget */}
        <div className="absolute bottom-0 right-0 z-20 flex w-full flex-col items-end gap-3 sm:w-auto">
          <AnimatePresence>
            {open && (
              <motion.div
                key="panel"
                id={panelId}
                role="dialog"
                aria-modal="false"
                aria-labelledby={titleId}
                aria-describedby={descId}
                onKeyDown={handlePanelKeyDown}
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 14, scale: 0.96 }}
                animate={reduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
                exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 14, scale: 0.96 }}
                transition={{ duration: reduceMotion ? 0.12 : 0.3, ease: [0.22, 1, 0.36, 1] }}
                className="w-full max-w-[calc(100vw-2.5rem)] overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl shadow-slate-900/10 sm:w-80 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40"
              >
                {/* header */}
                <div className="relative overflow-hidden bg-gradient-to-br from-indigo-600 to-violet-600 px-5 py-4 text-white">
                  <div
                    aria-hidden="true"
                    className="newsxfloat-shimmer-anim pointer-events-none absolute inset-0 [background:linear-gradient(110deg,transparent_35%,rgba(255,255,255,0.18)_50%,transparent_65%)] [background-size:220%_100%]"
                  />
                  <div className="relative flex items-start justify-between gap-3">
                    <div>
                      <p className="flex items-center gap-1.5 text-xs font-medium text-indigo-100">
                        <MailIcon />
                        Signal Weekly
                      </p>
                      <h3 id={titleId} className="mt-1 text-lg font-semibold leading-tight">
                        Get the Thursday letter
                      </h3>
                    </div>
                    <button
                      type="button"
                      onClick={closePanel}
                      aria-label="Minimize newsletter signup"
                      className={`-mr-1.5 -mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-indigo-100 transition-colors hover:bg-white/15 hover:text-white ${focusRing} focus-visible:ring-offset-indigo-600`}
                    >
                      <CloseIcon />
                    </button>
                  </div>
                </div>

                {/* body */}
                <div className="px-5 py-5">
                  {status === "success" ? (
                    <div role="status" aria-live="polite" className="flex flex-col items-center text-center">
                      <span className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
                        <CheckIcon />
                      </span>
                      <p className="mt-3 text-sm font-semibold text-slate-900 dark:text-slate-100">You&rsquo;re on the list.</p>
                      <p className="mt-1.5 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                        Check your inbox for a confirmation link. The next issue lands this Thursday.
                      </p>
                      <button
                        type="button"
                        onClick={resetForm}
                        className={`mt-4 inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-indigo-600 transition-colors hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10 ${focusRing}`}
                      >
                        Add another email
                      </button>
                    </div>
                  ) : (
                    <form onSubmit={handleSubmit} noValidate>
                      <p id={descId} className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                        Three techniques worth your time, plus one teardown. Pick how often you&rsquo;d like it.
                      </p>

                      <label htmlFor={emailId} className="mt-4 block text-sm font-medium text-slate-800 dark:text-slate-200">
                        Email address
                      </label>
                      <div
                        className={shake ? "newsxfloat-shake-anim" : undefined}
                        onAnimationEnd={() => setShake(false)}
                      >
                        <input
                          ref={emailRef}
                          id={emailId}
                          type="email"
                          inputMode="email"
                          autoComplete="email"
                          placeholder="you@studio.com"
                          value={email}
                          aria-invalid={status === "error"}
                          aria-describedby={status === "error" ? errorId : undefined}
                          onChange={(event) => {
                            setEmail(event.target.value);
                            if (status === "error") setStatus("idle");
                          }}
                          className={`mt-1.5 w-full rounded-xl border bg-white px-3.5 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 transition-colors dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-600 ${focusRing} ${
                            status === "error"
                              ? "border-rose-400 dark:border-rose-500/60"
                              : "border-slate-300 hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-600"
                          }`}
                        />
                      </div>
                      {status === "error" && (
                        <p id={errorId} role="alert" className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">
                          Enter a valid email address (like you@studio.com).
                        </p>
                      )}

                      <fieldset className="mt-4">
                        <legend className="text-sm font-medium text-slate-800 dark:text-slate-200">How often?</legend>
                        <div className="mt-1.5 grid grid-cols-2 gap-2">
                          {FREQUENCIES.map((freq) => (
                            <label key={freq.value} className="relative cursor-pointer">
                              <input
                                type="radio"
                                name={groupId}
                                value={freq.value}
                                checked={frequency === freq.value}
                                onChange={() => setFrequency(freq.value)}
                                className="peer sr-only"
                              />
                              <span
                                className={`flex flex-col rounded-xl border border-slate-300 px-3 py-2 transition-colors peer-checked:border-indigo-500 peer-checked:bg-indigo-50 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-700 dark:peer-checked:border-indigo-400 dark:peer-checked:bg-indigo-500/10 dark:peer-focus-visible:ring-offset-slate-900`}
                              >
                                <span className="text-sm font-medium text-slate-800 dark:text-slate-200">{freq.label}</span>
                                <span className="text-xs text-slate-500 dark:text-slate-500">{freq.hint}</span>
                              </span>
                            </label>
                          ))}
                        </div>
                      </fieldset>

                      <button
                        type="submit"
                        className={`group mt-5 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 active:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-400 ${focusRing} focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900`}
                      >
                        Subscribe
                        <span className="transition-transform group-hover:translate-x-0.5">
                          <ArrowIcon />
                        </span>
                      </button>

                      <p className="mt-3 text-center text-xs text-slate-500 dark:text-slate-500">
                        No spam. Unsubscribe anytime.
                      </p>
                    </form>
                  )}
                </div>
              </motion.div>
            )}
          </AnimatePresence>

          {/* floating toggle */}
          <button
            ref={toggleRef}
            type="button"
            onClick={() => setOpen((value) => !value)}
            aria-expanded={open}
            aria-controls={panelId}
            aria-label={open ? "Close newsletter signup" : "Open newsletter signup"}
            className={`group relative inline-flex items-center gap-2 self-end rounded-full bg-slate-900 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-slate-900/25 transition-colors hover:bg-slate-800 dark:bg-white dark:text-slate-900 dark:shadow-black/40 dark:hover:bg-slate-100 ${focusRing} ${
              open || reduceMotion ? "" : "newsxfloat-bob-anim"
            }`}
          >
            <span className="text-indigo-300 dark:text-indigo-500">
              <MailIcon />
            </span>
            <span>{open ? "Close" : "Subscribe"}</span>
            {unread && !open && (
              <span className="absolute -right-0.5 -top-0.5 flex h-3 w-3">
                <span className="newsxfloat-ping-anim absolute inline-flex h-full w-full rounded-full bg-rose-400" />
                <span className="relative inline-flex h-3 w-3 rounded-full bg-rose-500 ring-2 ring-slate-900 dark:ring-white" />
              </span>
            )}
          </button>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →