Web InnoventixFreeCode

Empty Notifications

Original · free

no notifications state

byWeb InnoventixReact + Tailwind
emptynotificationsstates
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/empty-notifications.json
empty-notifications.tsx
"use client";

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

type FilterKey = "all" | "mentions" | "deploys";

type Channel = {
  key: FilterKey;
  label: string;
  hint: string;
  count: number;
};

type Digest = {
  id: string;
  label: string;
  detail: string;
};

const CHANNELS: Channel[] = [
  { key: "all", label: "All activity", hint: "Everything routed to this inbox", count: 0 },
  { key: "mentions", label: "Mentions", hint: "Only threads where you're tagged", count: 0 },
  { key: "deploys", label: "Deploys", hint: "Build + release events from CI", count: 0 },
];

const EMPTY_COPY: Record<FilterKey, { title: string; body: string }> = {
  all: {
    title: "Inbox zero since 09:41",
    body: "Nothing new has landed here today. When a teammate tags you or a pipeline finishes, it shows up in this list within a few seconds.",
  },
  mentions: {
    title: "No one has tagged you",
    body: "Mentions from pull requests, issue threads, and comments will collect here. Your last mention was cleared 3 days ago.",
  },
  deploys: {
    title: "No deploy events waiting",
    body: "The last pipeline finished clean on main at 09:38 and was auto-archived. Failed builds stay pinned to the top until you dismiss them.",
  },
};

const DIGESTS: Digest[] = [
  { id: "d-realtime", label: "Real time", detail: "Ping me the second something lands" },
  { id: "d-hourly", label: "Hourly", detail: "Batch everything into one summary" },
  { id: "d-daily", label: "Daily at 9am", detail: "One quiet recap, nothing else" },
];

function BellIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-7 w-7">
      <path
        d="M6 9a6 6 0 1 1 12 0c0 3.1.7 4.9 1.5 6H4.5C5.3 13.9 6 12.1 6 9Z"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinejoin="round"
      />
      <path d="M9.5 18a2.5 2.5 0 0 0 5 0" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
      <path d="m5 12.5 4.2 4.2L19 7" stroke="currentColor" strokeWidth="2.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 12h13m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function SlidersIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <path d="M4 7h9m4 0h3M4 17h3m4 0h9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
      <circle cx="15" cy="7" r="2.2" stroke="currentColor" strokeWidth="1.6" />
      <circle cx="9" cy="17" r="2.2" stroke="currentColor" strokeWidth="1.6" />
    </svg>
  );
}

export default function EmptyNotifications() {
  const reduced = useReducedMotion();
  const groupId = useId();
  const panelRef = useRef<HTMLDivElement>(null);
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const [filter, setFilter] = useState<FilterKey>("all");
  const [digest, setDigest] = useState<string>("d-hourly");
  const [muted, setMuted] = useState<boolean>(false);
  const [saved, setSaved] = useState<boolean>(false);

  const copy = useMemo(() => EMPTY_COPY[filter], [filter]);
  const activeIndex = useMemo(() => CHANNELS.findIndex((c) => c.key === filter), [filter]);

  function focusTab(index: number) {
    const next = (index + CHANNELS.length) % CHANNELS.length;
    setFilter(CHANNELS[next].key);
    tabRefs.current[next]?.focus();
  }

  function onTabKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      event.preventDefault();
      focusTab(index + 1);
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      event.preventDefault();
      focusTab(index - 1);
    } else if (event.key === "Home") {
      event.preventDefault();
      focusTab(0);
    } else if (event.key === "End") {
      event.preventDefault();
      focusTab(CHANNELS.length - 1);
    }
  }

  function saveDelivery() {
    setSaved(true);
    window.setTimeout(() => setSaved(false), 2400);
  }

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes enotif-ripple {
          0% { transform: scale(0.7); opacity: 0.55; }
          70% { opacity: 0; }
          100% { transform: scale(2.1); opacity: 0; }
        }
        @keyframes enotif-drift {
          0%, 100% { transform: translateY(0px); }
          50% { transform: translateY(-6px); }
        }
        @keyframes enotif-sweep {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(220%); }
        }
        .enotif-ripple { animation: enotif-ripple 4.2s ease-out infinite; }
        .enotif-ripple-b { animation-delay: 1.4s; }
        .enotif-ripple-c { animation-delay: 2.8s; }
        .enotif-drift { animation: enotif-drift 6s ease-in-out infinite; }
        .enotif-sweep { animation: enotif-sweep 3.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .enotif-ripple, .enotif-drift, .enotif-sweep { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 h-64 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.14),transparent_70%)] dark:bg-[radial-gradient(60%_100%_at_50%_0%,rgba(129,140,248,0.16),transparent_70%)]"
      />

      <div className="relative mx-auto w-full max-w-3xl">
        <div className="mb-8 flex flex-wrap items-end justify-between gap-4">
          <div>
            <p className="text-xs font-semibold uppercase tracking-[0.16em] text-indigo-600 dark:text-indigo-400">
              Notifications
            </p>
            <h2 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">Your activity inbox</h2>
          </div>
          <button
            type="button"
            onClick={() => setMuted((m) => !m)}
            aria-pressed={muted}
            className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-3.5 py-2 text-sm font-medium text-slate-700 transition-colors hover:border-slate-400 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
          >
            <span
              className={`h-1.5 w-1.5 rounded-full ${muted ? "bg-amber-500" : "bg-emerald-500"}`}
              aria-hidden="true"
            />
            {muted ? "Muted until tomorrow" : "Mute for 24 hours"}
          </button>
        </div>

        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div
            role="tablist"
            aria-label="Notification channels"
            className="flex gap-1 border-b border-slate-200 bg-slate-50/80 p-1.5 dark:border-slate-800 dark:bg-slate-950/40"
          >
            {CHANNELS.map((channel, i) => {
              const selected = channel.key === filter;
              return (
                <button
                  key={channel.key}
                  ref={(el) => {
                    tabRefs.current[i] = el;
                  }}
                  role="tab"
                  type="button"
                  id={`${groupId}-tab-${channel.key}`}
                  aria-selected={selected}
                  aria-controls={`${groupId}-panel`}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => setFilter(channel.key)}
                  onKeyDown={(e) => onTabKeyDown(e, i)}
                  className={`relative flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
                    selected
                      ? "text-slate-900 dark:text-white"
                      : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                  }`}
                >
                  {selected ? (
                    <motion.span
                      layoutId={`${groupId}-pill`}
                      transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }}
                      className="absolute inset-0 rounded-xl bg-white shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"
                      aria-hidden="true"
                    />
                  ) : null}
                  <span className="relative flex items-center justify-center gap-2">
                    {channel.label}
                    <span className="rounded-md bg-slate-200/70 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-slate-500 dark:bg-slate-700/60 dark:text-slate-400">
                      {channel.count}
                    </span>
                  </span>
                </button>
              );
            })}
          </div>

          <div
            ref={panelRef}
            role="tabpanel"
            id={`${groupId}-panel`}
            aria-labelledby={`${groupId}-tab-${filter}`}
            tabIndex={0}
            className="px-6 py-14 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 sm:px-10"
          >
            <div className="flex flex-col items-center text-center">
              <div className="relative mb-8 grid h-24 w-24 place-items-center">
                {!reduced && (
                  <>
                    <span
                      aria-hidden="true"
                      className="enotif-ripple absolute h-20 w-20 rounded-full border border-indigo-400/40 dark:border-indigo-400/30"
                    />
                    <span
                      aria-hidden="true"
                      className="enotif-ripple enotif-ripple-b absolute h-20 w-20 rounded-full border border-indigo-400/40 dark:border-indigo-400/30"
                    />
                    <span
                      aria-hidden="true"
                      className="enotif-ripple enotif-ripple-c absolute h-20 w-20 rounded-full border border-indigo-400/40 dark:border-indigo-400/30"
                    />
                  </>
                )}
                <div className="enotif-drift relative grid h-16 w-16 place-items-center overflow-hidden rounded-2xl border border-slate-200 bg-white text-indigo-600 shadow-sm dark:border-slate-700 dark:bg-slate-800 dark:text-indigo-300">
                  <BellIcon />
                  {!reduced && (
                    <span
                      aria-hidden="true"
                      className="enotif-sweep absolute inset-y-0 -left-6 w-6 skew-x-[-20deg] bg-gradient-to-r from-transparent via-white/60 to-transparent dark:via-white/10"
                    />
                  )}
                </div>
              </div>

              <AnimatePresence mode="wait" initial={false}>
                <motion.div
                  key={filter}
                  initial={reduced ? false : { opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduced ? { opacity: 1 } : { opacity: 0, y: -6 }}
                  transition={{ duration: reduced ? 0 : 0.22, ease: [0.22, 1, 0.36, 1] }}
                  className="max-w-md"
                >
                  <h3 className="text-lg font-semibold tracking-tight text-slate-900 dark:text-white">{copy.title}</h3>
                  <p className="mt-2 text-sm leading-relaxed text-slate-500 dark:text-slate-400">{copy.body}</p>
                </motion.div>
              </AnimatePresence>

              <p aria-live="polite" className="sr-only">
                {`${CHANNELS[activeIndex].label}: 0 notifications. ${copy.title}.`}
              </p>

              <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
                <a
                  href="#activity-log"
                  className="inline-flex items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
                >
                  Browse activity log
                  <ArrowIcon />
                </a>
                <a
                  href="#notification-rules"
                  className="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                >
                  <SlidersIcon />
                  Edit routing rules
                </a>
              </div>
            </div>
          </div>

          <div className="border-t border-slate-200 bg-slate-50/80 px-6 py-6 dark:border-slate-800 dark:bg-slate-950/40 sm:px-10">
            <fieldset>
              <legend className="text-sm font-semibold text-slate-800 dark:text-slate-200">
                While it&rsquo;s quiet, pick a delivery rhythm
              </legend>
              <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
                Applies to {CHANNELS[activeIndex].label.toLowerCase()} &mdash; {CHANNELS[activeIndex].hint.toLowerCase()}.
              </p>
              <div className="mt-4 grid gap-2 sm:grid-cols-3">
                {DIGESTS.map((option) => {
                  const checked = digest === option.id;
                  return (
                    <label
                      key={option.id}
                      className={`group relative flex cursor-pointer flex-col gap-1 rounded-xl border p-3 text-left transition-colors focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 focus-within:ring-offset-slate-50 dark:focus-within:ring-offset-slate-950 ${
                        checked
                          ? "border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-500/10"
                          : "border-slate-200 bg-white hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700"
                      }`}
                    >
                      <input
                        type="radio"
                        name={`${groupId}-digest`}
                        value={option.id}
                        checked={checked}
                        onChange={() => setDigest(option.id)}
                        className="sr-only"
                      />
                      <span className="flex items-center justify-between gap-2">
                        <span
                          className={`text-sm font-medium ${
                            checked ? "text-indigo-700 dark:text-indigo-200" : "text-slate-700 dark:text-slate-300"
                          }`}
                        >
                          {option.label}
                        </span>
                        <span
                          aria-hidden="true"
                          className={`grid h-4 w-4 shrink-0 place-items-center rounded-full border transition-colors ${
                            checked
                              ? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-400 dark:bg-indigo-400 dark:text-slate-900"
                              : "border-slate-300 text-transparent dark:border-slate-600"
                          }`}
                        >
                          <CheckIcon />
                        </span>
                      </span>
                      <span className="text-xs leading-snug text-slate-500 dark:text-slate-400">{option.detail}</span>
                    </label>
                  );
                })}
              </div>
            </fieldset>

            <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
              <p className="text-xs text-slate-500 dark:text-slate-400">
                Delivery to <span className="font-medium text-slate-700 dark:text-slate-300">salman@webinnoventix.com</span>
              </p>
              <div className="flex items-center gap-3">
                <span
                  aria-live="polite"
                  className={`text-xs font-medium text-emerald-600 transition-opacity dark:text-emerald-400 ${
                    saved ? "opacity-100" : "opacity-0"
                  }`}
                >
                  {saved ? "Preference saved" : ""}
                </span>
                <button
                  type="button"
                  onClick={saveDelivery}
                  className="rounded-lg bg-indigo-600 px-3.5 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950"
                >
                  Save preference
                </button>
              </div>
            </div>
          </div>
        </div>

        <p className="mt-6 text-center text-xs text-slate-400 dark:text-slate-500">
          Notifications older than 30 days are archived automatically and stay searchable in the activity log.
        </p>
      </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 →