ER

Martes, 26 de septiembre

Inicio

Nota fijada

Lisboa en cuatro días

Editada hace 2 min

12

Ideas

8

Recetas

React + MotionOverlays

Menu latéral façon iOS

Un menu latéral en verre dépoli glisse depuis le côté pendant que la page recule et s'assombrit. On le ferme d'un glissement.

Réglages

90%
28px

Code

import { useEffect, useRef, useState, type RefObject } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Archive, House, Menu, Star, Users, X } from "lucide-react";

const SECTIONS = [
  { id: "inicio", label: "Accueil", icon: House },
  { id: "destacadas", label: "À la une", icon: Star },
  { id: "compartidas", label: "Partagées", icon: Users },
  { id: "archivo", label: "Fichier", icon: Archive },
];

const NOTE_ART = "radial-gradient(120% 90% at 100% 0%, #ffc49b 0%, transparent 50%), linear-gradient(135deg, #f0527a 0%, #a855f7 60%, #6d5dfc 100%)";

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

// Modal accesible: enfoca el primer control (o el marcado con data-autofocus), atrapa Tab, cierra con Esc y devuelve el foco
function useDialog(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
  const close = useRef(onClose);
  useEffect(() => {
    close.current = onClose;
  });
  useEffect(() => {
    if (!open) return;
    const previous = document.activeElement as HTMLElement | null;
    const node = ref.current;
    const first =
      node?.querySelector<HTMLElement>("[data-autofocus]") ?? node?.querySelector<HTMLElement>(FOCUSABLE);
    first?.focus({ preventScroll: true });
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        e.preventDefault();
        close.current();
        return;
      }
      if (e.key !== "Tab" || !node) return;
      const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE));
      if (!items.length) return;
      const firstItem = items[0];
      const lastItem = items[items.length - 1];
      if (!firstItem || !lastItem) return;
      if (!node.contains(document.activeElement)) {
        e.preventDefault();
        firstItem.focus();
      } else if (e.shiftKey && document.activeElement === firstItem) {
        e.preventDefault();
        lastItem.focus();
      } else if (!e.shiftKey && document.activeElement === lastItem) {
        e.preventDefault();
        firstItem.focus();
      }
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      previous?.focus({ preventScroll: true });
    };
  }, [open, ref]);
}

interface ScaleDrawerProps {
  side?: "left" | "right";
  /** Escala de la página al abrir, en % */
  scale?: number;
  radius?: number;
}

export default function ScaleDrawer({
  side = "left",
  scale = 90,
  radius = 28,
}: ScaleDrawerProps) {
  const [open, setOpen] = useState(false);
  const [section, setSection] = useState("inicio");
  const panel = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();
  const transition = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 300, damping: 32, mass: 0.9 };
  const dir = side === "left" ? 1 : -1;
  const current = SECTIONS.find((s) => s.id === section) ?? SECTIONS[0]!;
  useDialog(panel, open, () => setOpen(false));

  return (
    // h-dvh: la raíz ocupa la ventana y hace de fondo negro; las capas son absolute dentro de ella
    <div className="relative h-dvh w-full overflow-hidden bg-black">
      <motion.div
        animate={
          open
            ? { scale: scale / 100, x: 40 * dir, borderRadius: radius, filter: "brightness(0.7)" }
            : { scale: 1, x: 0, borderRadius: 0, filter: "brightness(1)" }
        }
        transition={transition}
        style={{ transformOrigin: side === "left" ? "100% 50%" : "0% 50%" }}
        className="absolute inset-0 overflow-hidden bg-[#f5f5f7] text-[#1d1d1f] dark:bg-[#161618] dark:text-[#f5f5f7]"
      >
        <div inert={open} aria-hidden={open} className="mx-auto flex h-full max-w-md flex-col px-5 pt-4">
          <div className={"flex items-center justify-between " + (side === "right" ? "flex-row-reverse" : "")}>
            <button
              type="button"
              aria-label="Ouvrir le menu"
              aria-expanded={open}
              aria-haspopup="dialog"
              onClick={() => setOpen(true)}
              className="grid h-10 w-10 place-items-center rounded-full bg-white shadow-[0_1px_2px_rgba(0,0,0,0.06),0_6px_16px_rgba(0,0,0,0.06)] transition active:scale-95 dark:bg-white/10 dark:shadow-none"
            >
              <Menu className="h-[18px] w-[18px]" strokeWidth={1.5} />
            </button>
            <span className="grid h-9 w-9 place-items-center rounded-full bg-gradient-to-br from-[#fcd34d] to-[#f97316] text-[12px] font-semibold text-white">
              ER
            </span>
          </div>
          <p className="mt-7 text-[13px] text-[#86868b]">Mardi 26 septembre</p>
          <AnimatePresence mode="wait" initial={false}>
            <motion.h2
              key={current.id}
              initial={{ opacity: 0, y: 8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -8 }}
              transition={{ duration: reduce ? 0 : 0.18 }}
              className="text-[32px] font-semibold leading-tight tracking-[-0.03em]"
            >
              {current.label}
            </motion.h2>
          </AnimatePresence>
          <div
            className="relative mt-5 overflow-hidden rounded-[22px] p-5 text-white shadow-[0_10px_30px_-10px_rgba(168,85,247,0.6)]"
            style={{ background: NOTE_ART }}
          >
            <p className="text-[12px] font-medium text-white/75">Note épinglée</p>
            <p className="mt-7 text-[20px] font-semibold leading-tight tracking-[-0.02em]">Lisbonne en quatre jours</p>
            <p className="mt-1 text-[13px] text-white/75">Modifiée il y a 2 min</p>
          </div>
          <div className="mt-3 grid grid-cols-2 gap-3">
            <div className="rounded-[20px] bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:bg-white/[0.06] dark:shadow-none">
              <p className="text-[22px] font-semibold leading-none tracking-[-0.03em]">12</p>
              <p className="mt-1.5 text-[13px] text-[#86868b]">Idées</p>
            </div>
            <div className="rounded-[20px] bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:bg-white/[0.06] dark:shadow-none">
              <p className="text-[22px] font-semibold leading-none tracking-[-0.03em]">8</p>
              <p className="mt-1.5 text-[13px] text-[#86868b]">Recettes</p>
            </div>
          </div>
        </div>
      </motion.div>

      <AnimatePresence>
        {open && (
          <>
            <motion.div key="catcher" aria-hidden className="absolute inset-0" onClick={() => setOpen(false)} />
            <motion.div
              key="drawer"
              ref={panel}
              role="dialog"
              aria-modal="true"
              aria-label="Menu"
              initial={{ x: side === "left" ? "-110%" : "110%" }}
              animate={{ x: 0 }}
              exit={{ x: side === "left" ? "-110%" : "110%" }}
              transition={transition}
              drag="x"
              dragConstraints={{ left: 0, right: 0 }}
              dragElastic={side === "left" ? { left: 0.5, right: 0.05 } : { left: 0.05, right: 0.5 }}
              onDragEnd={(_, info) => {
                if (info.offset.x * dir < -70 || info.velocity.x * dir < -500) setOpen(false);
              }}
              className={"absolute inset-y-3 flex w-[min(78%,300px)] touch-pan-y flex-col rounded-[28px] border border-white/60 bg-white/80 p-3 text-[#1d1d1f] shadow-[0_8px_20px_rgba(0,0,0,0.15),0_30px_70px_rgba(0,0,0,0.35)] backdrop-blur-2xl backdrop-saturate-150 dark:border-white/10 dark:bg-[#232326]/80 dark:text-[#f5f5f7] " + (side === "left" ? "left-3" : "right-3")}
            >
              <div className="flex items-center justify-between px-2 pb-3 pt-1">
                <span className="flex items-center gap-2 text-[15px] font-semibold tracking-[-0.01em]">
                  <span className="h-6 w-6 rounded-[8px]" style={{ background: NOTE_ART }} />
                  Notes
                </span>
                <button
                  type="button"
                  aria-label="Fermer"
                  onClick={() => setOpen(false)}
                  className="grid h-8 w-8 place-items-center rounded-full bg-black/5 transition hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/20"
                >
                  <X className="h-4 w-4" strokeWidth={1.5} />
                </button>
              </div>
              <ul className="space-y-0.5">
                {SECTIONS.map(({ id, label, icon: Icon }) => (
                  <li key={id}>
                    <button
                      type="button"
                      aria-current={id === section ? "page" : undefined}
                      onClick={() => {
                        setSection(id);
                        setOpen(false);
                      }}
                      className="relative flex h-11 w-full items-center gap-3 rounded-2xl px-3 text-left text-[15px] outline-none transition-colors hover:bg-black/[0.04] focus-visible:bg-black/[0.06] dark:hover:bg-white/[0.06] dark:focus-visible:bg-white/10"
                    >
                      {id === section && (
                        <motion.span
                          layoutId="drawer-active"
                          transition={transition}
                          className="absolute inset-0 rounded-2xl bg-black/[0.06] dark:bg-white/10"
                        />
                      )}
                      <Icon className="relative h-[18px] w-[18px]" strokeWidth={1.5} />
                      <span className={"relative " + (id === section ? "font-medium" : "")}>{label}</span>
                    </button>
                  </li>
                ))}
              </ul>
              <div className="mt-auto flex items-center gap-3 rounded-2xl p-2">
                <span className="grid h-9 w-9 place-items-center rounded-full bg-gradient-to-br from-[#fcd34d] to-[#f97316] text-[12px] font-semibold text-white">
                  ER
                </span>
                <div className="min-w-0">
                  <p className="truncate text-[14px] font-medium leading-tight">Elena Ruiz</p>
                  <p className="text-[12px] text-[#86868b]">Forfait Pro</p>
                </div>
              </div>
            </motion.div>
          </>
        )}
      </AnimatePresence>
    </div>
  );
}

Prompt pour l’IA

Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.

Plus dans Overlays

Où l’utiliser

Ce menu latéral façon iOS convient aux applications où l'on navigue surtout par le menu : une appli de notes, le tableau de bord d'un outil de réservation ou l'espace client d'une petite boutique. Comme la page recule au lieu de disparaître, l'utilisateur comprend qu'il n'a pas quitté l'écran en cours.

Il s'ouvre à gauche ou à droite. Une échelle autour de 90 % paraît naturelle ; en dessous, la page semble trop lointaine. Le code à copier occupe toute la hauteur de l'écran : il est prévu pour envelopper l'application entière, pas une seule section.