돌아가기
#takeoff
#bento
#grid
#Next.js
#react

Bento Grid 스타일로 프로젝트 소개 페이지 꾸미기

프로젝트의 기능을 한 눈에 볼 수 있는 페이지를 작성하려고 몇 가지 디자인 레퍼런스를 둘러보던 중 눈에 들어온 디자인이 있었다.

애플의 프로젝트 소개 페이지. 이런 형식의 디자인을 벤토 그리드 디자인이라고 한다.

애플의 프로젝트 소개 페이지

로딩 중...bento-apple-design

애플은 제품 프레젠테이션에 이러한 디자인을 적용시켜, 정보를 깔끔하고 시각적으로 구분되도록 하였다.

프레젠테이션 페이지에서 사용되는 벤토 그리드 디자인 뿐만 아니라 사용자 인터페이스 디자인에서의 벤토 그리드가 성공적으로 구현되면서 최신 웹 및 앱 디자인 분야에서 트렌드로 떠오르고 있다.

벤토 그리드의 의미

벤토 라는 단어는 일본식 도시락 요리를 가리키는 말로 사용하며1 벤토 그리드는 컨텐츠를 도시락 상자처럼 구분된 섹션으로 체계적으로 정리하는 디자인이라고 한다.

로딩 중...bento-grid 벤토 박스.

벤토 그리드 레퍼런스 탐색

벤토 그리드 디자인이 적용된 여러 웹 페이지를 찾아보았다.

21st, magicui에서 Nextjs에서 구현 예제에 대해 확인했고, Bento Grids에서 다양한 디자인 레퍼런스를 참고하였다.

로딩 중...bento-grid-reference

벤토 그리드 구현

CSS Grid 기본 개념

벤토 그리드는 CSS Grid Layout을 기반으로 구현된다. CSS Grid는 2차원 레이아웃 시스템으로, 행(row)과 열(column)을 동시에 다룰 수 있어 복잡한 레이아웃을 쉽게 만들 수 있다.

일반적인 CSS Grid 속성들:

  • display: grid - Grid 컨테이너 생성
  • grid-template-columns - 열의 크기와 개수 정의
  • grid-template-rows - 행의 크기와 개수 정의
  • grid-gap (또는 gap) - 그리드 아이템 간의 간격
  • grid-column / grid-row - 아이템의 위치와 크기 지정

Tailwind CSS로 구현하기

이 프로젝트에서는 Tailwind CSS를 사용하여 모든 스타일링을 처리한다. Tailwind의 Grid 유틸리티 클래스들:

  • grid - display: grid 적용
  • grid-cols-{n} - 열 개수 설정 (예: grid-cols-3)
  • grid-rows-{n} - 행 개수 설정 (예: grid-rows-3)
  • gap-{size} - 그리드 간격 설정
  • col-span-{n} - 열 스팬 설정
  • row-span-{n} - 행 스팬 설정
  • auto-rows-[minmax(200px,auto)] - 행의 최소/최대 높이 설정

참고 문서: Tailwind CSS Grid

기본 벤토 그리드 예제

먼저 간단한 3x3 그리드로 벤토 그리드의 기본 원리를 살펴보자:

1
2
3
4
5
6
tsx
export default function BentoGrid() {
    return <div className="grid grid-cols-3 grid-rows-3 gap-3 aspect-square">
        <div className="bg-zinc-800 rounded-lg col-span-2 p-4">
            <div className="text-2xl font-bold">1</div>
        </div>
        <div className="bg-zinc-800 rounded-lg p-4">
            <div className="text-2xl font-bold">2</div>
        </div>
        <div className="bg-zinc-800 rounded-lg row-span-2 p-4">
            <div className="text-2xl font-bold">3</div>
        </div>
        <div className="bg-zinc-800 rounded-lg col-span-2 p-4">
            <div className="text-2xl font-bold">4</div>
        </div>
        <div className="bg-zinc-800 rounded-lg p-4">
            <div className="text-2xl font-bold">5</div>
        </div>
        <div className="bg-zinc-800 rounded-lg p-4">
            <div className="text-2xl font-bold">6</div>
        </div>
    </div>;
};

기능 정의 하기

벤토 그리드의 각 셀을 채워야 하기에 미리 컨텐츠를 정의하였다. 프로젝트가 무엇인지 한 눈에 보여야 하는 것이 목적이므로 각 셀에는 프로젝트의 기능을 명시하기로 하였다.

Takeoff. 프로젝트의 기능은 5가지로 추릴 수 있었다.

  • 콘텐츠 자동 정리
  • AI 타임라인
  • LLM 벤치마크
  • AI 주간 뉴스
  • 웹훅 알림

벤토 그리드 컴포넌트 구현

디자인 시스템과 컬러 팔레트

벤토 그리드 셀의 디자인을 시작하기 전에 일관된 디자인 시스템을 정의했다:

  • 컬러 팔레트: zinc 계열 색상으로 통일
  • 타이포그래피: 제목은 text-lg font-bold, 설명은 text-sm font-semibold
  • 간격: gap-4
  • 둥근 모서리: rounded-lg

셀 컴포넌트 구현

셀 컴포넌트는 magicui의 bento grid 디자인을 참고하여 구현했다.

주요 디자인 결정사항:

  1. bg-gradient-to-b from-zinc-900/20 to-zinc-950로 하단 방향으로 명도를 낮춰 텍스트 가독성 향상.
  2. Motion 라이브러리를 사용하여 자연스러운 인터랙션 구현

테스트 입니다.

테스트 입니다.

이동하기
tsx
import { ArrowRightIcon } from "lucide-react";
import { motion } from "motion/react";
import { useRef } from "react";
 
export default function TakeoffBentoGridItem({
  icon,
  title,
  href,
  description,
  className,
  background,
}: {
  icon: React.ReactNode;
  title: string;
  href: string;
  description: string;
  className?: string;
  background?: React.ReactNode;
}) {
  const containerRef = useRef<HTMLDivElement>(null);
 
  return (
    <motion.div
      className={`group relative flex flex-col-reverse border border-zinc-700 rounded-lg overflow-hidden
cursor-pointer backdrop-blur-sm
${className}
        `}
      ref={containerRef}
      transition={{
        duration: 0.1,
      }}
      onClick={() => window.open(href, "_blank")}
    >
      <div className="relative flex flex-col gap-1 p-6 top-8 group-hover:top-0 transition-all duration-300">
        <span className="w-12 h-12 group-hover:w-8 group-hover:h-8 transition-all duration-300 text-zinc-400">
          {icon}
        </span>
        <h3 className="text-lg font-bold text-zinc-200">{title}</h3>
        <p className="text-zinc-400 text-sm font-semibold leading-relaxed">
          {description}
        </p>
        <motion.div className="relative opacity-0 group-hover:opacity-100 flex w-fit items-center transition-all duration-300">
          <span className=" text-zinc-200 text-sm font-semibold">
            이동하기
          </span>
          <ArrowRightIcon className="w-4 h-4 text-zinc-200" />
          <span className="absolute -bottom-[2px] left-0 w-0 group-hover:w-full transition-all duration-300 border-b-[1px] border-zinc-200" />
        </motion.div>
      </div>
 
      <div className="-z-50 absolute top-0 left-0 w-full h-full transition-all duration-300">
        <div
          className="absolute top-0 left-0 w-full h-full 
        bg-gradient-to-b from-zinc-900/20 from-40% to-zinc-950 to-90% z-50
        group-hover:from-zinc-900/10 transition-all duration-300
        "
        />
        {background}
      </div>
    </motion.div>
  );
}
 

애니메이션과 인터랙션 세부사항

구현된 인터랙션 기능들:

  1. 텍스트 영역 슬라이드 업: 호버 시 top-8에서 top-0으로 이동
  2. 아이콘 크기 변경: w-12 h-12에서 w-8 h-8로 축소
  3. CTA 버튼 페이드 인: 호버 시 "이동하기" 버튼이 나타나며 언더라인 애니메이션 추가
  4. 배경 오버레이 변화: 그라데이션 강도가 호버 시 변경되어 배경 이미지가 더 선명하게 보임
css
/* 텍스트 영역 애니메이션 */
.group-hover:top-0 transition-all duration-300
 
/* 배경 오버레이 변화 */
group-hover:from-zinc-900/10 transition-all duration-300

그리드 레이아웃 설계

앞서 프로젝트의 기능을 5개로 정의했으므로 그리드는 5개의 셀을 가져야 한다.

각 셀의 배경과 콘텐츠를 고려하여 다음과 같이 배치했다:

테스트 입니다.

테스트 입니다.

이동하기

테스트 입니다.

테스트 입니다.

이동하기

테스트 입니다.

테스트 입니다.

이동하기

테스트 입니다.

테스트 입니다.

이동하기

테스트 입니다.

테스트 입니다.

이동하기
tsx
"use client";
 
import TakeoffBentoGridItem from "@/app/takeoff/TakeoffBentoGridItem";
import { PackageSearch, Table, Bell, Calendar, Globe } from "lucide-react";
import { TbTimeline } from "react-icons/tb";
 
export default function TakeoffBentoGridTest() {
  return (
    <div className="flex flex-col gap-12 mb-16 p-8">
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-[minmax(200px,auto)]">
        <div className="md:col-span-2 lg:col-span-2">
          <TakeoffBentoGridItem
            icon={<PackageSearch className="w-full h-full" />}
            href={`#`}
            title="테스트 입니다."
            description="테스트 입니다."
            className="w-full h-full"
            background={<div />}
          />
        </div>
 
        <div className="md:col-span-1 lg:col-span-1 row-span-2">
          <TakeoffBentoGridItem
            icon={<TbTimeline className="w-full h-full" />}
            href={`#`}
            title="테스트 입니다."
            description="테스트 입니다."
            className="w-full h-full"
            background={<div />}
          />
        </div>
 
        <div className="md:col-span-2 lg:col-span-2 row-span-2">
          <TakeoffBentoGridItem
            icon={<Table className="w-full h-full" />}
            href={`#`}
            title="테스트 입니다."
            description="테스트 입니다."
            className="w-full h-full"
            background={<div />}
          />
        </div>
 
        <div className="md:row-span-2">
          <TakeoffBentoGridItem
            icon={<Bell className="w-full h-full" />}
            href={`#`}
            title="테스트 입니다."
            description="테스트 입니다."
            className="w-full h-full"
            background={<div />}
          />
        </div>
 
        <div className="md:col-span-2">
          <TakeoffBentoGridItem
            icon={<Calendar className="w-full h-full" />}
            href={`#`}
            title="테스트 입니다."
            description="테스트 입니다."
            className="w-full h-full"
            background={<div />}
          />
        </div>
      </div>
    </div>
  );
}
 

셀 배경 구현

벤토 그리드에서는 각 셀마다 배경을 추가하여 셀의 기능을 직관적으로 표현한다.

로딩 중...bento-grid-background

셀 배경 구현 예시

Motion 라이브러리를 활용하여 기능에 맞는 애니메이션 배경을 구현했다.

주간 뉴스 배경

  • 네온 라인이 흘러가며 데이터 처리 과정을 시각화
  • 실시간으로 진행상황을 보여주는 터미널 스타일 UI
  • "게시글 정리 중" → "뉴스 선택 중" → "주간 뉴스 생성 완료" 순서로 진행
tsx
import Takeoff from "@/app/components/icon/Takeoff";
import { AnimatePresence, motion, useTime, useTransform } from "framer-motion";
import { Check } from "lucide-react";
import { useEffect, useId, useState } from "react";
 
const NEON_DURATION = 500;
 
export default function WeeklyNewsBentoBackground() {
  const time = useTime();
  const [consoleStep, setConsoleStep] = useState(0);
 
  useEffect(() => {
    const length = 20000;
    const offset = 4000;
    time.on("change", (value) => {
      const step = Math.floor((value % length) / offset);
      setConsoleStep(step);
    });
  }, []);
 
  return (
    <div className="relative flex flex-row items-center justify-center w-full h-full">
      <div
        className="flex items-center justify-center w-24 h-24 p-4 bg-gradient-to-bl from-zinc-900 via-zinc-800 to-zinc-850
        ring-8 ring-zinc-900
       rounded-lg"
      >
        <Takeoff color="white" />
      </div>
      <div className="relative w-16 h-16 overflow-hidden">
        <NeonLine width={8} top={2} delay={0} />
        <NeonLine width={8} top={2} delay={0.25} />
        <NeonLine width={8} top={2} delay={0.5} />
        <NeonLine width={8} top={2} delay={0.75} />
 
        <NeonLine width={16} top={32} delay={0} />
        <NeonLine width={16} top={32} delay={0.5} />
 
        <NeonLine width={24} top={62} delay={0} />
        <NeonLine width={8} top={62} delay={0.5} />
      </div>
      <div className="flex flex-col h-full w-32 pt-2">
        <div className="bg-gradient-to-bl from-zinc-900 via-zinc-800 to-zinc-850 h-full w-full rounded-lg p-2 flex flex-col gap-2
         overflow-hidden">
          <AnimatePresence>
            {consoleStep > 0 && (
              <ConsoleOrganize
                preText="게시글 정리 중..."
                postText="게시글 정리 완료"
                key={`0-console-organize`}
              />
            )}
            {consoleStep > 1 && (
              <ConsoleOrganize
                preText="뉴스 선택 중..."
                postText="뉴스 선택 완료"
                key={`1-console-organize`}
              />
            )}
            {consoleStep > 2 && (
              <ConsoleOrganize
                preText="주간 뉴스 생성 중..."
                postText="주간 뉴스 생성 완료"
                key={`2-console-organize`}
              />
            )}
            {consoleStep > 3 && (
              <PublishWeeklyNews key={`3-publish-weekly-news`} />
            )}
          </AnimatePresence>
        </div>
      </div>
 
      
    </div>
  );
}
 
function NeonLine({
  width,
  top,
  delay,
}: {
  width: number;
  top: number;
  delay: number;
}) {
  const time = useTime();
  const [hidden, setHidden] = useState(false);
  const x = useTransform(time, (value) => {
    const t = (value - delay * NEON_DURATION) % NEON_DURATION;
    return (t / NEON_DURATION) * 64;
  });
 
  useEffect(() => {
    time.on("change", (value) => {
      if (value < delay * NEON_DURATION) {
        setHidden(false);
      } else {
        setHidden(true);
      }
    });
  }, [delay]);
 
  if (!hidden) {
    return null;
  }
 
  return (
    <motion.div
      className="absolute w-4 h-0.5 left-2 shadow-[0_0_10px_rgba(255,255,255,1)]
        bg-gradient-to-l from-white to-white/10 rounded-full -z-50
        "
      style={{ width, top, x }}
    />
  );
}
 
function ConsoleText({ text }: { text: string }) {
  return (
    <motion.div
      className="flex flex-row w-full gap-2"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      transition={{ duration: 0.5 }}
    >
      <Takeoff className="w-4 h-4" />
      <div className="text-white text-xs font-light">{text}</div>
    </motion.div>
  );
}
 
function ConsoleOrganize({ preText, postText }: { preText: string; postText: string }) {
  const id = useId();
  const time = useTime();
  const [done, setDone] = useState(false);
 
  useEffect(() => {
    time.on("change", (value) => {
      setDone(value > 3000);
    });
  }, []);
 
  return (
    <motion.div
      className="flex flex-col w-full gap-2"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0, y: -50 }}
      transition={{ duration: 0.5 }}
    >
      <div className="flex flex-row gap-2">
        <AnimatePresence mode="wait">
          {!done && (
            <>
              <Takeoff className="w-4 h-4" />
              <motion.div
                key={`${id}-loading-text`}
                className="text-zinc-300 text-xs font-light italic"
                initial={{ opacity: 0, y: 5 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -5 }}
                transition={{ duration: 0.3 }}
              >
                {preText}
              </motion.div>
            </>
          )}
          {done && (
            <>
              <Check className="w-4 h-4 text-green-400 stroke-2" />
              <motion.div
                key="done-text"
                className="text-zinc-300 text-xs font-light italic"
                initial={{ opacity: 0, y: 5 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -5 }}
                transition={{ duration: 0.3 }}
              >
                {postText}
              </motion.div>
            </>
          )}
        </AnimatePresence>
      </div>
 
      <AnimatePresence mode="wait">
        {!done && (
          <motion.div
            key="loading-content"
            className="flex flex-row gap-2"
            initial={{ opacity: 0, height: 0 }}
            animate={{ opacity: 1, height: "auto" }}
            exit={{ opacity: 0, height: 0 }}
            transition={{ duration: 0.3, ease: "easeInOut" }}
          >
            {/* 수직선 */}
            <div className="relative h-full w-[1px] ml-1.5">
              <div className="absolute top-0 left-0 w-full h-full bg-zinc-700" />
            </div>
 
            {/* 로딩 인디케이터 */}
            <div className="flex flex-col gap-2 w-full">
              <motion.div
                className="bg-gradient-to-l from-zinc-750/70 via-zinc-600 to-zinc-750/70
      h-2 w-full rounded-lg flex flex-col gap-2"
                style={{
                  backgroundSize: "200% 100%",
                  backgroundPosition: "0% 0%",
                }}
                initial={{ backgroundPosition: "0% 0%" }}
                animate={{ backgroundPosition: "200% 0%" }}
                transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
              />
              <div className="flex flex-row gap-2">
                <motion.div
                  className="bg-gradient-to-l from-zinc-750/70 via-zinc-600 to-zinc-750/70
      h-2 w-1/4 rounded-lg flex flex-col gap-2"
                  style={{
                    backgroundSize: "200% 100%",
                    backgroundPosition: "0% 0%",
                  }}
                  initial={{ backgroundPosition: "0% 0%" }}
                  animate={{ backgroundPosition: "200% 0%" }}
                  transition={{
                    duration: 2,
                    repeat: Infinity,
                    ease: "linear",
                  }}
                />
                <motion.div
                  className="bg-gradient-to-l from-zinc-750/70 via-zinc-600 to-zinc-750/70
      h-2 w-full rounded-lg flex flex-col gap-2"
                  style={{
                    backgroundSize: "200% 100%",
                    backgroundPosition: "0% 0%",
                  }}
                  initial={{ backgroundPosition: "0% 0%" }}
                  animate={{ backgroundPosition: "200% 0%" }}
                  transition={{
                    duration: 2,
                    repeat: Infinity,
                    ease: "linear",
                  }}
                />
              </div>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </motion.div>
  );
}
 
function PublishWeeklyNews() {
  const [isPublished, setIsPublished] = useState(false);
 
  useEffect(() => {
    const timer = setTimeout(() => {
      setIsPublished(true);
    }, 1500);
 
    return () => clearTimeout(timer);
  }, []);
 
  return (
    <motion.div
      className="flex flex-col w-full gap-2"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0, y: -50 }}
      transition={{ duration: 0.5 }}
    >
      <AnimatePresence mode="wait">
        {!isPublished ? (
          <motion.div
            key="publishing"
            className="relative p-[1px] rounded-sm overflow-hidden"
            initial={{ opacity: 0, scale: 0.95 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.95 }}
            transition={{ duration: 0.3 }}
          >
            {/* 애니메이션 그라데이션 테두리 */}
            <motion.div
              className="absolute inset-0 rounded-sm"
              style={{
                background: "linear-gradient(45deg, #3b82f6, #8b5cf6, #06d6a0, #3b82f6)",
                backgroundSize: "400% 400%",
              }}
              animate={{
                backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
              }}
              transition={{
                duration: 3,
                repeat: Infinity,
                ease: "linear",
              }}
            />
            
            {/* 내부 컨텐츠 */}
            <div className="relative bg-zinc-900 m-[1px] rounded-sm p-1 flex flex-row items-center gap-2">              
              <motion.div
                className="text-white text-xs font-medium"
                animate={{ opacity: [0.7, 1, 0.7] }}
                transition={{ duration: 1.5, repeat: Infinity }}
              >
                주간 뉴스 발행 중...
              </motion.div>
            </div>
          </motion.div>
        ) : (
          <motion.div
            key="published"
            className="relative p-[1px] rounded-sm overflow-hidden"
            initial={{ opacity: 0, scale: 0.95 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.95 }}
            transition={{ duration: 0.3 }}
          >
            {/* 성공 상태 그라데이션 테두리 */}
            <div
              className="absolute inset-0 rounded-sm bg-gradient-to-r from-green-400 via-emerald-500 to-green-400"
            />
            
            {/* 내부 컨텐츠 */}
            <div className="relative bg-zinc-900 m-[1px] rounded-sm p-1 flex flex-row items-center gap-2">
              <motion.div
                className="text-green-400 text-xs font-medium"
                initial={{ opacity: 0, y: 10 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{ delay: 0.3, duration: 0.3 }}
              >
                주간 뉴스 발행 완료
              </motion.div>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </motion.div>
  );
}
 

벤치마크 배경

  • 마우스 움직임에 따른 perspective 변화
  • SVG 기반 그래프로 벤치마크 결과 시각화
  • 무한 스크롤 애니메이션으로 다양한 벤치마크 이름 표시
Aider Polyglot
Fiction Live Bench
VPCT
GPQA Diamond
Frontier Math
Math Level 5
Otis Mock AIME
SWE Bench Verified
Weird ML
Balrog
Factorio
Geo Bench
Simple Bench
Aider Polyglot
Fiction Live Bench
VPCT
GPQA Diamond
Frontier Math
Math Level 5
Otis Mock AIME
SWE Bench Verified
Weird ML
Balrog
Factorio
Geo Bench
Simple Bench
OpenAI
Google
Meta
DeepSeek
Anthropic
Human Baseline
tsx
import { AnimatePresence, motion, useTime, useTransform } from "motion/react";
import React, { useEffect, useRef, useState } from "react";
import { TbArrowRight } from "react-icons/tb";
 
const benchmarks = [
  "Aider Polyglot",
  "Fiction Live Bench",
  "VPCT",
  "GPQA Diamond",
  "Frontier Math",
  "Math Level 5",
  "Otis Mock AIME",
  "SWE Bench Verified",
  "Weird ML",
  "Balrog",
  "Factorio",
  "Geo Bench",
  "Simple Bench",
];
 
const legends = [
  {
    name: "openai",
    color: "rgba(34, 197, 94, 0.8)",
    label: "OpenAI",
  },
  {
    name: "google",
    color: "rgba(59, 130, 246, 0.8)",
    label: "Google",
  },
  {
    name: "meta",
    color: "rgba(168, 85, 247, 0.8)",
    label: "Meta",
  },
  {
    name: "deepseek",
    color: "rgba(249, 115, 22, 0.8)",
    label: "DeepSeek",
  },
  {
    name: "anthropic",
    color: "rgba(239, 68, 68, 0.8)",
    label: "Anthropic",
  },
];
 
export default function BenchmarkBackground() {
  const duplicatedBenchmarks = [...benchmarks, ...benchmarks];
  const containerRef = useRef<HTMLDivElement>(null);
  const canvasContainerRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
      if (!containerRef.current) return;
 
      const container = containerRef.current;
      const canvasContainer = canvasContainerRef.current;
      if (!canvasContainer) return;
 
      const rect = container.getBoundingClientRect();
 
      // is in rect
      if (
        e.clientX < rect.left ||
        e.clientX > rect.right ||
        e.clientY < rect.top ||
        e.clientY > rect.bottom
      ) {
        canvasContainer.style.transform = "rotateX(0deg) rotateY(0deg)";
        return;
      }
 
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
 
      const containerWidth = rect.width;
      const containerHeight = rect.height;
 
      const normalizedX = (x / containerWidth) * 2 - 1; // -1 ~ 1
      const normalizedY = (y / containerHeight) * 2 - 1; // -1 ~ 1
 
      const maxOffset = 10;
 
      const transformX = -normalizedY * maxOffset;
      const transformY = normalizedX * maxOffset;
 
      canvasContainer.style.transform = `perspective(1000px) rotateX(${transformX}deg) rotateY(${transformY}deg)`;
    };
 
    window.addEventListener("mousemove", handleMouseMove);
 
    return () => {
      window.removeEventListener("mousemove", handleMouseMove);
    };
  }, []);
 
  return (
    <div ref={containerRef} className="flex flex-col w-full h-full relative">
      <motion.div
        className="flex flex-row items-center justify-center gap-3 absolute mt-2 whitespace-nowrap"
        initial={{ x: "0%" }}
        animate={{ x: "-50%" }}
        transition={{
          duration: 15,
          repeat: Infinity,
          ease: "linear",
        }}
      >
        {duplicatedBenchmarks.map((benchmark, index) => (
          <div
            key={`${benchmark}-${index}`}
            className="bg-zinc-900 px-3 py-2 rounded-xl text-sm font-semibold text-nowrap flex-shrink-0
             font-stretch-50% tracking-wider border-[1px] border-zinc-800
             bg-[radial-gradient(circle_at_center,var(--color-zinc-900)_0%,var(--color-zinc-850)_100%)]
             "
          >
            {benchmark}
          </div>
        ))}
      </motion.div>
      <div
        ref={canvasContainerRef}
        className="flex flex-col gap-2 justify-center items-center m-auto p-3 rounded-2xl shadow-xl transform-3d
        bg-gradient-to-bl from-zinc-950/50 via-zinc-900/50 to-zinc-950/50
        "
      >
        <SvgGraph />
      </div>
    </div>
  );
}
 
function SvgGraph() {
  const [hide, setHide] = useState(false);
  const time = useTime();
  const progress = useTransform(time, (value) => {
    return (value % 14000) / 14000;
  });
 
  useEffect(() => {
    progress.on("change", (value) => {
      if (value < 0.8) {
        setHide(false);
      } else {
        setHide(true);
      }
    });
  }, []);
 
  const gridX = 90;
  const gridY = 50;
  const width = 450;
  const height = 250;
  const points = [
    { x: width * 0.1, y: height * 0.7, name: "google" },
    { x: width * 0.2, y: height * 0.8, name: "meta" },
    { x: width * 0.3, y: height * 0.3, name: "google" },
    { x: width * 0.4, y: height * 0.4, name: "meta" },
    { x: width * 0.5, y: height * 0.5, name: "deepseek" },
    { x: width * 0.6, y: height * 0.45, name: "meta" },
    { x: width * 0.7, y: height * 0.1, name: "anthropic" },
    { x: width * 0.8, y: height * 0.2, name: "deepseek" },
    { x: width * 0.9, y: height * 0.1, name: "openai" },
  ];
 
  return (
    <>
      <AnimatePresence>
        {!hide && (
          <div className="flex flex-row items-center justify-center gap-2">
            {legends.map((legend, index) => (
              <motion.div
                key={legend.name}
                className="flex flex-row items-center justify-center gap-2"
                initial={{ opacity: 0, scale: 0.5 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.5 }}
                transition={{
                  delay: 0.5 + index * 0.1,
                  duration: 0.3,
                }}
              >
                <div
                  className="w-2 h-2 rounded-full"
                  style={{ backgroundColor: legend.color }}
                />
                <div className="text-white text-sm font-semibold">
                  {legend.label}
                </div>
              </motion.div>
            ))}
          </div>
        )}
      </AnimatePresence>
      <svg width={width} height={height}>
        {/* 그리드 */}
        <AnimatePresence propagate>
          {!hide && (
            <>
              {[...Array(Math.floor(width / gridX))].map((_, i) => (
                <motion.path
                  key={i}
                  d={`M ${i * gridX + 10} 0 L ${i * gridX + 10} ${height}`}
                  fill="none"
                  stroke="rgba(255, 255, 255, 0.8)"
                  strokeWidth="0.2"
                  initial={{ opacity: 0, scale: 0 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={{ opacity: 0, scale: 0 }}
                  transition={{
                    delay: i * 0.1,
                    duration: 0.3,
                    ease: "easeInOut",
                  }}
                />
              ))}
              {[...Array(Math.floor(height / gridY))].map((_, i) => (
                <motion.path
                  key={i}
                  d={`M 0 ${i * gridY + 10} L ${width} ${i * gridY + 10}`}
                  fill="none"
                  stroke="rgba(255, 255, 255, 0.8)"
                  strokeWidth="0.2"
                  initial={{ opacity: 0, scale: 0 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={{ opacity: 0, scale: 0 }}
                  transition={{
                    delay: i * 0.1,
                    duration: 0.3,
                    ease: "easeInOut",
                  }}
                />
              ))}
 
              {/* Human baseline */}
              <motion.g>
                <motion.path
                  d={`M 0 70 L ${width} 70`}
                  fill="none"
                  stroke="rgba(200, 200, 200, 0.9)"
                  strokeWidth="0.8"
                  strokeDasharray="10 10"
                  initial={{ d: `M 0 70 L 0 70` }}
                  animate={{ d: `M 0 70 L ${width} 70` }}
                  exit={{ d: `M 0 70 L 0 70` }}
                  transition={{
                    delay: 0.5,
                    duration: 0.3,
                  }}
                />
                <motion.text
                  x={60}
                  y={60}
                  textAnchor="middle"
                  fill="rgba(200, 200, 200, 0.5)"
                  fontSize="12"
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                  transition={{
                    delay: 0.5,
                    duration: 0.3,
                  }}
                >
                  Human Baseline
                </motion.text>
              </motion.g>
 
              {/* 점 */}
              {points.map((point, index) => (
                <React.Fragment key={index}>
                  <motion.circle
                    cx={point.x}
                    cy={point.y}
                    r={3}
                    fill={
                      legends.find((legend) => legend.name === point.name)
                        ?.color || "rgba(255, 255, 255, 0.8)"
                    }
                    initial={{ opacity: 0, scale: 0 }}
                    animate={{ opacity: 1, scale: 1 }}
                    exit={{ opacity: 0, scale: 0 }}
                    transition={{
                      delay: index * 0.1 + 0.8,
                      duration: 0.3,
                      ease: "easeInOut",
                    }}
                  />
                  <AnimatePresence>
                  {point.y < height * 0.3 && (
                    <motion.circle
                      cx={point.x}
                      cy={point.y}
                      r={3}
                      fill="none"
                      stroke={
                        legends.find((legend) => legend.name === point.name)
                          ?.color || "rgba(255, 255, 255, 0.8)"
                      }
                      strokeWidth="0.4"
                      initial={{
                        r: 3,
                        opacity: 0,
                      }}
                      animate={{
                        r: 10,
                        opacity: 1,
                      }}
                      transition={{
                        duration: 2,
                        delay: index * 0.1 + 0.8,
                        ease: "linear",
                        repeat: Infinity,
                      }}
                    />
                  )}
                  </AnimatePresence>
                </React.Fragment>
              ))}
            </>
          )}
        </AnimatePresence>
      </svg>
    </>
  );
}

자동 정리 배경

  • 두 데이터 소스(Hacker News, Reddit)에서 중앙 서버로 데이터가 흘러가는 구조 시각화
  • AnimatedBeam으로 데이터 플로우를 보여주는 부드러운 애니메이션
  • DotPattern 배경
tsx
import Image from "next/image";
import { useRef } from "react";
import { SiReddit, SiYcombinator } from "react-icons/si";
import { AnimatedBeam } from "../AnimatedBeam";
import { FaServer } from "react-icons/fa";
import { DotPattern } from "@/app/components/ui/dot-pattern-background";
import { cn } from "@/app/libs/utils";
 
export default function AutoOrganizeBentoBackground() {
  const containerRef = useRef<HTMLDivElement>(null);
  const from1Ref = useRef<HTMLDivElement>(null);
  const from2Ref = useRef<HTMLDivElement>(null);
  const toRef = useRef<HTMLDivElement>(null);
 
  return (
    <div
      className="relative flex flex-col items-center justify-center w-full h-full gap-20"
      ref={containerRef}
    >
      <DotPattern
        className={cn(
          "[mask-image:radial-gradient(300px_circle_at_center,transparent,white)]"
        )}
      />
      <div className="relative p-2 bg-zinc-900 rounded-lg">
        <div
          className="flex flex-row items-center justify-center gap-16 p-2 px-4
      bg-gradient-to-bl from-zinc-900/70 via-zinc-800/70 to-zinc-900/70 rounded-lg
      "
        >
          <div ref={from1Ref} className="z-10">
            <SiYcombinator size={30} />
          </div>
          <div className="flex flex-col gap-2 z-10" ref={toRef}>
            <FaServer className="w-14 h-14" gradientUnits="userSpaceOnUse" />
          </div>
          <div ref={from2Ref} className="z-10">
            <SiReddit size={30} />
          </div>
        </div>
      </div>
      <AnimatedBeam
        className="z-0"
        duration={3}
        containerRef={containerRef}
        fromRef={from1Ref}
        toRef={toRef}
      />
      <AnimatedBeam
        className="z-0"
        duration={3}
        containerRef={containerRef}
        fromRef={from2Ref}
        toRef={toRef}
        reverse
      />
    </div>
  );
}

타임라인 배경

  • 10초 사이클로 진행되는 AI 발전사 타임라인 애니메이션
  • 각 시점마다 카드 표시
  • Motion의 useTimeuseTransform을 활용한 타이밍 제어
  • cubicBezier 이징으로 자연스러운 단계별 진행 효과
tsx
import {
  useTime,
  useTransform,
  motion,
  cubicBezier,
  AnimatePresence,
} from "motion/react";
import { useCallback, useMemo, useState, useEffect } from "react";
 
export default function TimelineBentoBackground() {
  const time = useTime();
 
  const progress = useTransform(time, (latest) => {
    const cycle = 10000; // 10초 사이클
    const cycleProgress = (latest % cycle) / cycle;
    return cycleProgress; // 0에서 1까지
  });
 
  const getCurrentStep = useCallback((prog: number) => {
    if (prog < 0.15) return -2;
    if (prog < 0.3) return 0;
    if (prog < 0.5) return 1;
    if (prog < 0.9) return 2;
    return -1;
  }, []);
 
  const currentStep = useTransform(progress, getCurrentStep);
 
  const stepData = useMemo(
    () => [
      {
        position: 0.1,
        year: "2016",
        title: "AlphaGo",
        description:
          "딥마인드의 알파고가 바둑 세계 챔피언 이세돌을 꺾으며, 많은 이들이 불가능하다고 여겼던 AI의 잠재력을 입증했습니다.",
      },
      {
        position: 0.35,
        year: "2022",
        title: "ChatGPT",
        description:
          "OpenAI는 'ChatGPT: 대화를 위한 언어 모델 최적화'라는 블로그 포스트를 발표했습니다. 처음에는 조용한 연구 미리보기였으나, ChatGPT는 곧 세계 최대 AI 제품이 되어 생성형 AI의 새 시대를 열었습니다.",
      },
      {
        position: 0.6,
        year: "2025",
        title: "Deepseek Panic",
        description:
          "중국 연구소 DeepSeek이 6710억 개 파라미터를 가진 오픈소스 모델 DeepSeek v3를 공개하였습니다. 이 모델은 굉장히 저렴한 비용으로 뛰어난 성능을 발휘했습니다.",
      },
    ],
    []
  );
 
  // 각 스텝에 도달했을 때 잠시 멈추는 효과 + cleanup 단계
  const heightPercentage = useTransform(
    progress,
    [0, 0.15, 0.2, 0.3, 0.35, 0.5, 0.55, 0.7, 0.75, 0.9, 0.95, 1.0],
    [
      "0%",
      "10%",
      "10%",
      "35%",
      "35%",
      "60%",
      "60%",
      "100%",
      "100%",
      "100%",
      "0%",
      "0%",
    ],
    { ease: cubicBezier(0.25, 0.1, 0.25, 1.0) }
  );
 
  return (
    <div className="flex flex-col items-center px-4 w-full h-full relative">
      {/* 타임라인 세로선 */}
      <div className="relative flex flex-col items-center h-full w-full">
        <motion.div
          className="w-0.5 bg-zinc-800 absolute top-0 left-12 -translate-x-1/2"
          style={{
            height: heightPercentage,
          }}
          initial={{ height: "0%" }}
        />
 
        {/* 각 스텝의 점들과 카드들 */}
        <AnimatePresence>
          {stepComponents.map((step, index) => (
            <motion.div
              key={index}
              className="absolute left-4"
              style={{ top: `${step.position * 100}%` }}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0, y: -400 }}
              layout
            >
              {/* 스텝 점 */}
              <motion.div
                className="absolute w-3 h-3 bg-white rounded-full left-8 -translate-x-1/2 transform border-2 border-gray-800"
              />
 
              {/* 년도 */}
              <motion.div className="absolute left-0 -translate-y-1/4 -translate-x-1/2 text-sm font-bold">
                {step.year}
              </motion.div>
 
              {/* 타임라인 카드 */}
              <motion.div
                className="absolute left-12 top-4 -translate-y-1/2 
                backdrop-blur-sm rounded-2xl border-[1px] border-zinc-900 p-3 min-w-[160px]"
              >
                <div className="text-white/80 text-sm font-bold mt-1">
                  {step.title}
                </div>
                <div className="text-white/80 text-xs mt-1">
                  {step.description.slice(0, 20)}...
                </div>
              </motion.div>
            </motion.div>
          ))}
        </AnimatePresence>
      </div>
    </div>
  );
}

알림 배경

  • Discord 스타일의 알림 카드들이 순차적으로 나타나고 사라지는 애니메이션
  • 실제 AI 뉴스 콘텐츠를 미리 정의하여 현실감 있는 알림 데모 구현
  • AnimatePresencepopLayout 모드로 자연스러운 레이아웃 변화
tsx
import Image from "next/image";
import { motion, AnimatePresence } from "motion/react";
import { useState, useEffect } from "react";
 
const notifications = [
  {
    title: "새로운 뉴스",
    description: "새로운 뉴스가 추가되었습니다.",
    date: "2025-01-03"
  },
  {
    title: "LLM 평가의 새로운 기준",
    description: "과학 분야 대규모 언어 모델 평가 벤치마크 공개",
    date: "2025-06-26"
  },
  {
    title: "양자 AI 알고리즘, 슈퍼컴퓨너 성능 능가",
    description: "커널 기반 머신러닝에서 양자 속도 향상 성공적 시연",
    date: "2025-07-03"
  },
  {
    title: "AI 시대의 새로운 핵심 역량: 컨텍스트 엔지니어링",
    description: "프롬프트 엔지니어링에서 컨텍스트 엔지니어링으로 논의의 초점 이동",
    date: "2025-07-05"
  }
];
 
export default function NotificationBentoBackground() {
  const [visibleItems, setVisibleItems] = useState<number[]>([]);
  const [removingItems, setRemovingItems] = useState<number[]>([]);
 
  useEffect(() => {
    const animationLoop = () => {
      // 모든 아이템을 순차적으로 보이게 하기
      notifications.forEach((_, index) => {
        setTimeout(() => {
          setVisibleItems(prev => [...prev, index]);
        }, index * 800); // 0.8초 간격으로 순차적으로 나타남
      });
 
      // 모든 아이템이 보인 후 2초 후에 순차적으로 제거 시작
      setTimeout(() => {
        notifications.forEach((_, index) => {
          setTimeout(() => {
            setRemovingItems(prev => [...prev, index]);
          }, index * 400); // 0.4초 간격으로 순차적으로 제거
        });
 
        setTimeout(() => {
          setVisibleItems([]);
          setRemovingItems([]);
        }, notifications.length * 400 + 600);
      }, notifications.length * 800 + 2000);
    };
 
    animationLoop();
    const totalCycleTime = notifications.length * 800 + 2000 + notifications.length * 400 + 1200;
    const interval = setInterval(animationLoop, totalCycleTime);
 
    return () => clearInterval(interval);
  }, []);
 
  return (
    <div className="flex flex-col items-start justify-center w-full h-full gap-1">
      <AnimatePresence mode="popLayout">
        {visibleItems.map((index) => {
          const isRemoving = removingItems.includes(index);
          return (
            <motion.div
              className="w-full"
              key={`notification-${index}`}
              initial={{ opacity: 0, y: 40, scale: 0.9 }}
              animate={{ 
                opacity: isRemoving ? 0 : 1, 
                y: isRemoving ? -200 : 0, 
                scale: 1
              }}
              exit={{ opacity: 0, y: -200 }}
              transition={{ 
                duration: 0.6,
                ease: "easeOut"
              }}
              layout
            >
            <NotificationItem
              title={notifications[index].title}
              description={notifications[index].description}
              date={notifications[index].date}
            />
          </motion.div>
          );
        })}
      </AnimatePresence>
    </div>
  );
}

그리드와 셀 배경 통합

이제 그리드와 셀 배경을 통합하여 완성된 벤토 그리드 컴포넌트를 구현했다. 추가로 각 셀에 맞는 제목과 내용을 작성했다.

콘텐츠 자동 정리

Hackernews/Reddit에서 인기있는 AI 관련 글들을 자동으로 수집하고 정리하여 게시합니다.

이동하기

AI 타임라인

2015-2025년 AI 발전사

이동하기

LLM 벤치마크

13종의 벤치마크 결과 정리

이동하기
Aider Polyglot
Fiction Live Bench
VPCT
GPQA Diamond
Frontier Math
Math Level 5
Otis Mock AIME
SWE Bench Verified
Weird ML
Balrog
Factorio
Geo Bench
Simple Bench
Aider Polyglot
Fiction Live Bench
VPCT
GPQA Diamond
Frontier Math
Math Level 5
Otis Mock AIME
SWE Bench Verified
Weird ML
Balrog
Factorio
Geo Bench
Simple Bench
OpenAI
Google
Meta
DeepSeek
Anthropic
Human Baseline

실시간 알림

Discord Webhook으로 새로운 글 업로드마다 실시간 알림을 받을 수 있습니다.

이동하기

AI 주간 뉴스

AI가 일주일에 한 번 주간 뉴스를 정리하여 게시합니다.

이동하기

완성된 결과물은 여기서 확인할 수 있다.

추가로

Tailwind Grid Generator를 활용하여 그리드 레이아웃을 쉽게 작성할 수 있다.

Footnotes

  1. 벤토 나무위키

Takeoff
이 게시글은 Takeoff 프로젝트의 포스트입니다.