Playground

Flipped Menu

October 2024

"use client";

import {useGSAP} from "@gsap/react";
import gsap from "gsap";
import React, {Fragment, useEffect, useRef, useState} from "react";

const dishes = ["Fish and Chips", "Sunday Roast", "Shepherd's Pie"];
const navigationLinks = ["Contact", "Recruitment", "Privacy"];

const FlippedMenu = () => {
    const [splitting, setSplitting] = useState<any>(null);
    const scope = useRef<HTMLDivElement>(null);
    const [isOpen, setIsOpen] = useState(false);
    const timelineRef = useRef<gsap.core.Timeline | null>(null);
    const btnWord1 = useRef<HTMLSpanElement>(null);
    const btnWord2 = useRef<HTMLSpanElement>(null);
    const menu = useRef<HTMLDivElement>(null);

    useEffect(() => {
        // @ts-expect-error no modules for typescript
        import("splitting").then((Splitting) => {
            setSplitting(() => Splitting.default);
        });
    }, []);

    useGSAP(
        async () => {
            if (!splitting && !scope.current) return;
            await splitting({target: scope.current});

            const chars1 = btnWord1.current?.querySelectorAll(".char");
            const chars2 = btnWord2.current?.querySelectorAll(".char");

            gsap.set([btnWord2.current, menu.current], {opacity: 1});

            if (!chars1?.length || !chars2?.length) return;

            timelineRef.current = gsap.timeline({
                paused: true,
                defaults: {duration: 0.4, ease: "power1.out", stagger: 0.05},
            });

            timelineRef.current
                .to(chars1, {transformOrigin: "top", rotateX: 90})
                .fromTo(
                    chars2,
                    {rotateX: 90, transformOrigin: "bottom"},
                    {rotateX: 0},
                    0
                )
                .fromTo(
                    menu.current,
                    {
                        rotateX: 20,
                        rotateY: -30,
                        xPercent: -120,
                        yPercent: -80,
                    },
                    {
                        xPercent: 0,
                        yPercent: 0,
                        rotateX: 0,
                        rotateY: 0,
                        ease: "expo.out",
                        duration: 0.6,
                    },
                    0
                );
        },
        {scope, dependencies: [splitting]}
    );

    const handleToggle = () => {
        if (!timelineRef.current) return;

        if (isOpen) {
            timelineRef.current.reverse();
        } else {
            timelineRef.current.play();
        }

        setIsOpen(!isOpen);
    };

    return (
        <article
            ref={scope}
            className="relative min-h-96 overflow-hidden bg-white font-herbik text-white/90 sm:aspect-3/2 sm:min-h-0">
            <div className="absolute left-10 top-10 z-10">
                <button
                    aria-expanded={isOpen}
                    aria-controls="menu"
                    onClick={handleToggle}
                    className="group relative -rotate-[10deg] rounded-[50%] border border-white bg-black px-7 py-3 text-sm font-bold uppercase shadow shadow-white/30 transition-transform duration-300 ease-out hover:-rotate-[14deg] hover:scale-110 sm:text-base [&_.char]:inline-grid">
                    <span
                        ref={btnWord1}
                        data-splitting
                        className="absolute left-1/2 top-1/2 inline-grid -translate-x-1/2 -translate-y-1/2 overflow-hidden whitespace-nowrap px-1">
                        Open
                    </span>
                    <span
                        ref={btnWord2}
                        data-splitting
                        className="inline-grid overflow-hidden whitespace-nowrap px-1 opacity-0">
                        Close
                    </span>
                    <span className="absolute left-4 top-1/2 size-1 -translate-y-1/2 rounded-full bg-current transition-all duration-300 ease-out group-hover:left-5"></span>
                    <span className="absolute right-4 top-1/2 size-1 -translate-y-1/2 rounded-full bg-current transition-all duration-300 ease-out group-hover:right-5"></span>
                </button>
            </div>

            <div className="absolute left-1/2 top-1/2 size-[95%] -translate-x-1/2 -translate-y-1/2 [perspective:500px] sm:h-[70%] sm:w-4/5">
                <div
                    ref={menu}
                    id="menu"
                    className="h-full w-full bg-black p-4 opacity-0 shadow shadow-black/80">
                    <div className="h-full w-full border-2 border-white p-1">
                        <div className="flex h-full w-full flex-col justify-between border border-white p-5 pt-16 sm:p-10 sm:pb-5">
                            <div className="flex flex-col">
                                {dishes.map((dish) => {
                                    return (
                                        <button
                                            key={dish}
                                            className="group flex items-center justify-between border-b border-white pl-2 text-lg text-white transition duration-300 first-of-type:border-t hover:bg-white hover:text-black">
                                            <span>{dish}</span>
                                            <span className="inline-block border-l border-white p-3">
                                                <svg
                                                    xmlns="http://www.w3.org/2000/svg"
                                                    fill="none"
                                                    viewBox="0 0 24 24"
                                                    strokeWidth={1.5}
                                                    stroke="currentColor"
                                                    className="size-4">
                                                    <path
                                                        strokeLinecap="round"
                                                        strokeLinejoin="round"
                                                        d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"
                                                    />
                                                </svg>
                                            </span>
                                        </button>
                                    );
                                })}
                            </div>
                            <nav className="flex items-center justify-end gap-x-2">
                                {navigationLinks.map((link) => {
                                    return (
                                        <Fragment key={link}>
                                            <button className="font-poppins text-sm transition duration-300 hover:text-white/70">
                                                {link}
                                            </button>
                                            <span className="inline-block size-1 rounded-full bg-white last-of-type:hidden"></span>
                                        </Fragment>
                                    );
                                })}
                            </nav>
                        </div>
                    </div>
                </div>
            </div>
        </article>
    );
};

export default FlippedMenu;
Previous

Spatial Tooltip

Next

Image Trail