Web InnoventixFreeCode

Basic Modal

Original · free

basic centred modal with overlay

byWeb InnoventixReact + Tailwind
modalbasicmodals
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/modal-basic.json
modal-basic.tsx
"use client";

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

const FOCUSABLE_SELECTOR =
  'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';

export default function ModalBasic() {
  const [open, setOpen] = useState(false);
  const [notify, setNotify] = useState(true);
  const [status, setStatus] = useState<string | null>(null);
  const reduceMotion = useReducedMotion();

  const triggerRef = useRef<HTMLButtonElement | null>(null);
  const panelRef = useRef<HTMLDivElement | null>(null);
  const cancelRef = useRef<HTMLButtonElement | null>(null);

  const titleId = useId();
  const descId = useId();
  const notifyId = useId();

  const close = useCallback(() => setOpen(false), []);

  const publish = useCallback(() => {
    setStatus(
      notify
        ? "Published 3 changes. The #design channel was notified."
        : "Published 3 changes. Notifications were skipped."
    );
    setOpen(false);
  }, [notify]);

  useEffect(() => {
    if (!open) return;

    const previouslyFocused = document.activeElement as HTMLElement | null;

    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        event.preventDefault();
        close();
        return;
      }
      if (event.key !== "Tab") return;

      const panel = panelRef.current;
      if (!panel) return;
      const nodes = Array.from(
        panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
      ).filter((el) => el.offsetParent !== null || el === document.activeElement);
      if (nodes.length === 0) return;

      const first = nodes[0];
      const last = nodes[nodes.length - 1];
      const active = document.activeElement;
      const inside = panel.contains(active);

      if (event.shiftKey) {
        if (active === first || !inside) {
          event.preventDefault();
          last.focus();
        }
      } else if (active === last || !inside) {
        event.preventDefault();
        first.focus();
      }
    };

    document.addEventListener("keydown", onKeyDown);
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";

    const raf = window.requestAnimationFrame(() => {
      cancelRef.current?.focus();
    });

    return () => {
      document.removeEventListener("keydown", onKeyDown);
      document.body.style.overflow = prevOverflow;
      window.cancelAnimationFrame(raf);
      previouslyFocused?.focus?.();
    };
  }, [open, close]);

  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 mbasic-float {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(0, -26px, 0) scale(1.06); }
        }
        @keyframes mbasic-drift {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(22px, 14px, 0); }
        }
        .mbasic-orb-a { animation: mbasic-float 13s ease-in-out infinite; }
        .mbasic-orb-b { animation: mbasic-drift 17s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .mbasic-orb-a, .mbasic-orb-b { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="mbasic-orb-a pointer-events-none absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-400/25 blur-3xl dark:bg-indigo-500/20"
      />
      <div
        aria-hidden="true"
        className="mbasic-orb-b pointer-events-none absolute -bottom-32 -right-16 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-500/15"
      />

      <div className="relative mx-auto max-w-2xl">
        <div className="flex flex-col items-start gap-4">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-slate-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Design system • v2.4
          </span>
          <h2 className="text-3xl font-semibold tracking-tight sm:text-4xl">
            Ship your component changes with confidence
          </h2>
          <p className="max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Review the diff, confirm the blast radius, then publish. Open the dialog
            to see an accessible, keyboard-navigable modal with a focus trap,
            scroll lock, and Escape-to-dismiss.
          </p>
        </div>

        <div className="mt-8 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="flex flex-wrap items-center justify-between gap-4">
            <div>
              <p className="text-sm font-medium text-slate-900 dark:text-slate-100">
                3 pending changes
              </p>
              <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                Button, Modal, and Tooltip are ready for release.
              </p>
            </div>
            <button
              ref={triggerRef}
              type="button"
              onClick={() => setOpen(true)}
              aria-haspopup="dialog"
              className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition 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-white dark:focus-visible:ring-offset-slate-900"
            >
              <svg
                aria-hidden="true"
                viewBox="0 0 20 20"
                fill="none"
                className="h-4 w-4"
              >
                <path
                  d="M10 3.5v9M10 3.5 6.5 7M10 3.5 13.5 7"
                  stroke="currentColor"
                  strokeWidth="1.6"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
                <path
                  d="M4 13.5v1.5A1.5 1.5 0 0 0 5.5 16.5h9a1.5 1.5 0 0 0 1.5-1.5v-1.5"
                  stroke="currentColor"
                  strokeWidth="1.6"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
              Review &amp; publish
            </button>
          </div>

          <p
            role="status"
            aria-live="polite"
            className="mt-4 min-h-5 text-sm font-medium text-emerald-600 dark:text-emerald-400"
          >
            {status}
          </p>
        </div>
      </div>

      <AnimatePresence>
        {open && (
          <div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center sm:p-6">
            <motion.div
              aria-hidden="true"
              onClick={close}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: reduceMotion ? 0 : 0.2 }}
              className="absolute inset-0 bg-slate-950/60 backdrop-blur-sm"
            />

            <motion.div
              ref={panelRef}
              role="dialog"
              aria-modal="true"
              aria-labelledby={titleId}
              aria-describedby={descId}
              initial={
                reduceMotion
                  ? { opacity: 0 }
                  : { opacity: 0, scale: 0.96, y: 12 }
              }
              animate={
                reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }
              }
              exit={
                reduceMotion
                  ? { opacity: 0 }
                  : { opacity: 0, scale: 0.96, y: 12 }
              }
              transition={{
                duration: reduceMotion ? 0 : 0.24,
                ease: [0.16, 1, 0.3, 1],
              }}
              className="relative w-full max-w-md overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900"
            >
              <div className="flex items-start gap-4 border-b border-slate-200 p-6 dark:border-slate-800">
                <span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-400">
                  <svg
                    aria-hidden="true"
                    viewBox="0 0 24 24"
                    fill="none"
                    className="h-5 w-5"
                  >
                    <path
                      d="M12 3v11M12 3 7.5 7.5M12 3l4.5 4.5"
                      stroke="currentColor"
                      strokeWidth="1.7"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                    <path
                      d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"
                      stroke="currentColor"
                      strokeWidth="1.7"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                </span>
                <div className="min-w-0 flex-1">
                  <h3
                    id={titleId}
                    className="text-lg font-semibold text-slate-900 dark:text-slate-100"
                  >
                    Publish to production
                  </h3>
                  <p
                    id={descId}
                    className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400"
                  >
                    You&apos;re about to push 3 component changes to the live
                    design system. They&apos;ll roll out to 214 downstream
                    projects immediately.
                  </p>
                </div>
                <button
                  type="button"
                  onClick={close}
                  aria-label="Close dialog"
                  className="-mr-1.5 -mt-1.5 shrink-0 rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-slate-800 dark:hover:text-slate-300"
                >
                  <svg
                    aria-hidden="true"
                    viewBox="0 0 20 20"
                    fill="none"
                    className="h-5 w-5"
                  >
                    <path
                      d="M6 6l8 8M14 6l-8 8"
                      stroke="currentColor"
                      strokeWidth="1.7"
                      strokeLinecap="round"
                    />
                  </svg>
                </button>
              </div>

              <div className="space-y-4 p-6">
                <ul className="space-y-2 rounded-xl bg-slate-50 p-4 text-sm dark:bg-slate-800/50">
                  {[
                    { name: "Button", note: "focus ring contrast fix" },
                    { name: "Modal", note: "focus trap + scroll lock" },
                    { name: "Tooltip", note: "delay + placement props" },
                  ].map((item) => (
                    <li
                      key={item.name}
                      className="flex items-center justify-between gap-3"
                    >
                      <span className="font-medium text-slate-800 dark:text-slate-200">
                        {item.name}
                      </span>
                      <span className="truncate text-slate-500 dark:text-slate-400">
                        {item.note}
                      </span>
                    </li>
                  ))}
                </ul>

                <label
                  htmlFor={notifyId}
                  className="flex cursor-pointer items-start gap-3 text-sm text-slate-700 dark:text-slate-300"
                >
                  <input
                    id={notifyId}
                    type="checkbox"
                    checked={notify}
                    onChange={(event) => setNotify(event.target.checked)}
                    className="mt-0.5 h-4 w-4 rounded border-slate-300 text-indigo-600 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-600 dark:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                  />
                  <span>
                    Notify the{" "}
                    <span className="font-medium text-slate-900 dark:text-slate-100">
                      #design
                    </span>{" "}
                    channel when the release lands.
                  </span>
                </label>
              </div>

              <div className="flex flex-col-reverse gap-3 border-t border-slate-200 p-6 sm:flex-row sm:justify-end dark:border-slate-800">
                <button
                  ref={cancelRef}
                  type="button"
                  onClick={close}
                  className="inline-flex items-center justify-center rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                >
                  Cancel
                </button>
                <button
                  type="button"
                  onClick={publish}
                  className="inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition 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-white dark:focus-visible:ring-offset-slate-900"
                >
                  Publish now
                </button>
              </div>
            </motion.div>
          </div>
        )}
      </AnimatePresence>
    </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 →