프로젝트의 기능을 한 눈에 볼 수 있는 페이지를 작성하려고 몇 가지 디자인 레퍼런스를 둘러보던 중 눈에 들어온 디자인이 있었다.
애플의 프로젝트 소개 페이지. 이런 형식의 디자인을 벤토 그리드 디자인이라고 한다.
로딩 중...
애플은 제품 프레젠테이션에 이러한 디자인을 적용시켜, 정보를 깔끔하고 시각적으로 구분되도록 하였다.
프레젠테이션 페이지에서 사용되는 벤토 그리드 디자인 뿐만 아니라 사용자 인터페이스 디자인에서의 벤토 그리드가 성공적으로 구현되면서 최신 웹 및 앱 디자인 분야에서 트렌드로 떠오르고 있다.
벤토 라는 단어는 일본식 도시락 요리를 가리키는 말로 사용하며1 벤토 그리드는 컨텐츠를 도시락 상자처럼 구분된 섹션으로 체계적으로 정리하는 디자인이라고 한다.
로딩 중...
벤토 박스.
벤토 그리드 디자인이 적용된 여러 웹 페이지를 찾아보았다.
21st, magicui에서 Nextjs에서 구현 예제에 대해 확인했고, Bento Grids에서 다양한 디자인 레퍼런스를 참고하였다.
로딩 중...
벤토 그리드는 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의 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 그리드로 벤토 그리드의 기본 원리를 살펴보자:
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가지로 추릴 수 있었다.
벤토 그리드 셀의 디자인을 시작하기 전에 일관된 디자인 시스템을 정의했다:
text-lg font-bold
, 설명은 text-sm font-semibold
gap-4
rounded-lg
셀 컴포넌트는 magicui의 bento grid 디자인을 참고하여 구현했다.
주요 디자인 결정사항:
bg-gradient-to-b from-zinc-900/20 to-zinc-950
로 하단 방향으로 명도를 낮춰 텍스트 가독성 향상.테스트 입니다.
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>
);
}
구현된 인터랙션 기능들:
top-8
에서 top-0
으로 이동w-12 h-12
에서 w-8 h-8
로 축소/* 텍스트 영역 애니메이션 */
.group-hover:top-0 transition-all duration-300
/* 배경 오버레이 변화 */
group-hover:from-zinc-900/10 transition-all duration-300
앞서 프로젝트의 기능을 5개로 정의했으므로 그리드는 5개의 셀을 가져야 한다.
각 셀의 배경과 콘텐츠를 고려하여 다음과 같이 배치했다:
테스트 입니다.
테스트 입니다.
테스트 입니다.
테스트 입니다.
테스트 입니다.
"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>
);
}
벤토 그리드에서는 각 셀마다 배경을 추가하여 셀의 기능을 직관적으로 표현한다.
로딩 중...
Motion 라이브러리를 활용하여 기능에 맞는 애니메이션 배경을 구현했다.
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>
);
}
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>
</>
);
}
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>
);
}
useTime
과 useTransform
을 활용한 타이밍 제어cubicBezier
이징으로 자연스러운 단계별 진행 효과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>
);
}
AnimatePresence
의 popLayout
모드로 자연스러운 레이아웃 변화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 관련 글들을 자동으로 수집하고 정리하여 게시합니다.
2015-2025년 AI 발전사
13종의 벤치마크 결과 정리
Discord Webhook으로 새로운 글 업로드마다 실시간 알림을 받을 수 있습니다.
AI가 일주일에 한 번 주간 뉴스를 정리하여 게시합니다.
완성된 결과물은 여기서 확인할 수 있다.
Tailwind Grid Generator를 활용하여 그리드 레이아웃을 쉽게 작성할 수 있다.