Floating Newsletter
Original · freefloating newsletter widget
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"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’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’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
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 quoteSimilar components
Browse all →
Inline Email Signup Bar
OriginalA wide banner that pairs a short heading with a single-line email field and subscribe button laid out side by side on desktop.

Boxed Newsletter Card
OriginalA centred, self-contained signup card with an icon badge, stacked email field and reassurance footer for dropping into a sidebar or narrow column.

Split Newsletter With Illustration
OriginalA two-column section combining copy and an inline email form with a gradient panel and hand-drawn SVG envelope illustration.

Contact Form With Details
OriginalA full contact section pairing email and location details with a name, email and message form on a bordered card.

Inline Newsletter
Originalinline newsletter bar

Card Newsletter
Originalnewsletter card

Split Image Newsletter
Originalnewsletter with image side

Gradient Newsletter
Originalgradient newsletter band

Modal Newsletter
Originalnewsletter popup card

Footer Bar Newsletter
Originalsticky footer newsletter bar

Benefits Newsletter
Originalnewsletter with benefit bullets

Dark Newsletter
Originaldark newsletter section

