Martes, 26 de septiembre
Inicio
Nota fijada
Lisboa en cuatro días
Editada hace 2 min
12
Ideas
8
Recetas
Seitenmenü im iOS-Stil
Ein gläsernes Seitenmenü gleitet herein, während die Seite dahinter kleiner und dunkler wird. Zum Schließen einfach wegwischen.
Einstellungen
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: "Start", icon: House },
{ id: "destacadas", label: "Highlights", icon: Star },
{ id: "compartidas", label: "Geteilt", icon: Users },
{ id: "archivo", label: "Datei", 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="Menü öffnen"
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]">Dienstag, 26. September</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">Angeheftete Notiz</p>
<p className="mt-7 text-[20px] font-semibold leading-tight tracking-[-0.02em]">Lissabon in vier Tagen</p>
<p className="mt-1 text-[13px] text-white/75">Vor 2 Min. bearbeitet</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]">Ideen</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]">Rezepte</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="Menü"
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 }} />
Notizen
</span>
<button
type="button"
aria-label="Schließen"
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]">Pro-Tarif</p>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}
Prompt für KI
Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.
Mehr aus Overlays
Wofür du ihn nutzen kannst
Dieses Navigationsmenü im iOS-Stil passt zu Apps, in denen man sich hauptsächlich über das Menü bewegt: eine Notiz-App, das Dashboard eines Buchungstools oder der Kundenbereich eines kleinen Shops. Weil die Seite nach hinten rückt, statt zu verschwinden, bleibt klar, dass man noch am selben Ort ist.
Das Menü kann links oder rechts aufgehen. Eine Skalierung um 90 % wirkt natürlich, deutlich weniger lässt die Seite zu weit weg erscheinen. Der kopierte Code nimmt die volle Bildschirmhöhe ein, er ist also als Rahmen für die ganze App gedacht.