Skip to content
77 changes: 33 additions & 44 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,41 @@
import * as styles from "./test.css";
import {
ArrowLeft,
ArrowRight,
Banner,
Benefit,
BottomSheetHeart,
BottomSheetHeartFill,
ChatFloating,
Check,
ChevronDown,
ChevronRightRounded,
ChevronRightSharp,
ChevronUp,
Delete,
Home,
Logo,
MakerHeart,
MakerHeartFill,
Search,
Share,
ShoppingCart,
Star,
} from "./assets/svg";

import { Header } from "./shared/components/header";
import { Content } from "./shared/components/content";
import Section1 from "./shared/components/section1";
import Section2 from "./shared/components/section2";
import Section3 from "./shared/components/section3";
import { TabBar } from "./shared/components/tab-bar/tab-bar";
import { useScrollableTabs } from "./shared/hooks/useScrollableTabs";

function App() {
const { productInfoRef, reviewRef, recommendRef, activeTab, handleTabClick } =
useScrollableTabs();

return (
<div className={styles.test}>
<MakerHeart width={24} height={24} />
<MakerHeartFill width={24} height={24} />
<BottomSheetHeart width={24} height={24} />
<BottomSheetHeartFill width={50} height={50} />
<ArrowLeft width={24} height={24} />
<ArrowRight width={24} height={24} />
<ChevronDown width={24} height={24} />
<ChevronRightRounded width={24} height={24} />
<ChevronRightSharp width={24} height={24} />
<ChevronUp width={24} height={24} />
<Banner width={200} height={200} />
<Benefit width={24} height={24} />
<ChatFloating width={24} height={24} />
<Check width={24} height={24} />
<Delete width={24} height={24} />
<Home width={24} height={24} />
<Logo width={24} height={24} />
<Search width={24} height={24} />
<Share width={24} height={24} />
<ShoppingCart width={24} height={24} />
<Star width={24} height={24} />
<Header />

{/* 내용들 - 탭바 위에 있을 캐러셀 부분 ㅎㅎ */}
{[...Array(20)].map((_, i) => (
<Content key={i} type="내용" />
))}

{/* 탭바 - sticky로 헤더에 닿으면!! 고정 */}
<TabBar activeTab={activeTab} onTabClick={handleTabClick} />

{/* 작품 정보, 후기, 추천 섹션들 - 탭바 아래 */}
<div ref={productInfoRef} data-section="product-info">
<Section1 />
</div>

<div ref={reviewRef} data-section="review">
<Section2 />
</div>

<div ref={recommendRef} data-section="recommend">
<Section3 />
</div>
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions src/shared/components/content.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { style } from "@vanilla-extract/css";
import { typographyVars } from "../styles/typography.css";

export const content = style({
...typographyVars.heading1,
});
7 changes: 7 additions & 0 deletions src/shared/components/content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as styles from "./content.css";
interface ContentProps {
type: string;
}
export const Content = ({ type }: ContentProps) => {
return <div className={styles.content}>{type}</div>;
};
8 changes: 8 additions & 0 deletions src/shared/components/header.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { style } from "@vanilla-extract/css";

export const header = style({
height: "5.6rem",
position: "sticky",
top: 0,
backgroundColor: "pink",
});
4 changes: 4 additions & 0 deletions src/shared/components/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import * as styles from "./header.css";
export const Header = () => {
return <div className={styles.header}>헤더</div>;
};
5 changes: 5 additions & 0 deletions src/shared/components/section.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { style } from "@vanilla-extract/css";

export const container = style({
border: "1px solid black",
});
13 changes: 13 additions & 0 deletions src/shared/components/section1.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Content } from "./content";
import * as styles from "./section.css";
function Section1() {
return (
<div className={styles.container}>
{[...Array(20)].map((_, i) => (
<Content key={i} type="section1" />
))}
</div>
);
}

export default Section1;
13 changes: 13 additions & 0 deletions src/shared/components/section2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Content } from "./content";
import * as styles from "./section.css";
function Section2() {
return (
<div className={styles.container}>
{[...Array(20)].map((_, i) => (
<Content key={i} type="section2" />
))}
</div>
);
}

export default Section2;
14 changes: 14 additions & 0 deletions src/shared/components/section3.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Content } from "./content";

import * as styles from "./section.css";
function Section3() {
return (
<div className={styles.container}>
{[...Array(20)].map((_, i) => (
<Content key={i} type="section3" />
))}
</div>
);
}

export default Section3;
38 changes: 38 additions & 0 deletions src/shared/components/tab-bar/tab-bar.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { color } from "@/shared/styles/tokens/color.css";
import { typographyVars } from "@/shared/styles/typography.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";

export const tabBar = style({
position: "sticky",
top: "5.6rem", // 헤더 높이만큼 띄워서 헤더 바로 아래에 붙도록
display: "flex",
backgroundColor: color.white[100],
borderBottom: `1px solid ${color.gray[200]}`,
zIndex: 10,
});

export const tab = recipe({
base: {
flex: 1,
textAlign: "center",
...typographyVars.body1,
padding: "1.2rem 1rem",
border: "none",
background: "none",
cursor: "pointer",
color: color.gray[300],
transition: "color 0.2s",
position: "relative",
marginBottom: "-1px", // tabBar의 border를 덮기 위해
borderBottom: `2px solid transparent`,
},
variants: {
active: {
true: {
color: color.black[100],
borderBottom: `2px solid ${color.black[100]}`,
},
},
},
});
29 changes: 29 additions & 0 deletions src/shared/components/tab-bar/tab-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as styles from "./tab-bar.css";

interface TabBarProps {
activeTab: "product-info" | "review" | "recommend";
onTabClick: (_tab: "product-info" | "review" | "recommend") => void;
}

export const TabBar = ({ activeTab, onTabClick }: TabBarProps) => {
const tabs = [
{ id: "product-info" as const, label: "작품 정보" },
{ id: "review" as const, label: "후기 634" },
{ id: "recommend" as const, label: "추천" },
];

return (
<div className={styles.tabBar}>
{tabs.map(({ id, label }) => (
<button
key={id}
type="button"
aria-selected={activeTab === id}
className={styles.tab({ active: activeTab === id })}
onClick={() => onTabClick(id)}>
{label}
</button>
))}
</div>
);
};
81 changes: 81 additions & 0 deletions src/shared/hooks/useScrollableTabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from "react";

const HEADER_HEIGHT = 56;
const TAB_BAR_HEIGHT = 46;

export function useScrollableTabs() {
const productInfoRef = useRef<HTMLDivElement>(null);
const reviewRef = useRef<HTMLDivElement>(null);
const recommendRef = useRef<HTMLDivElement>(null);

const [activeTab, setActiveTab] = useState<
"product-info" | "review" | "recommend"
>("product-info");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useScrollableTabs 내부에서 "product-info" | "review" | "recommend" 문자열 리터럴 타입이 많이 활용되는 것 같아요
해당 타입들은 별도 타입으로 정의하여 사용하는 것이 타입 안정성면에서 좋을 것 같습니당!!

// 타입 정의
export type TabType = "product-info" | "review" | "recommend";

// 타입 활용
const [activeTab, setActiveTab] = useState<TabType>("product-info");

const sectionId = entry.target.getAttribute("data-section") as TabType;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

섬세하다 섬세한 섬세하다 섬세한
(놓친 부분 세세히 봐주셔서 감사해요 😍)


// IntersectionObserver로 현재 섹션 감지
useEffect(() => {
const observerOptions = {
// 섹션이 화면의 중앙 즈음에 올 때
rootMargin: `-${window.innerHeight / 2}px 0px -${window.innerHeight / 2}px 0px`,
threshold: 0,
};

const observerCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const sectionId = entry.target.getAttribute("data-section") as
| "product-info"
| "review"
| "recommend";

if (sectionId) {
setActiveTab(sectionId);
}
}
});
};

const sectionObserver = new IntersectionObserver(
observerCallback,
observerOptions
);

// 각 섹션 관찰 시작
if (productInfoRef.current) sectionObserver.observe(productInfoRef.current);
if (reviewRef.current) sectionObserver.observe(reviewRef.current);
if (recommendRef.current) sectionObserver.observe(recommendRef.current);

return () => {
sectionObserver.disconnect();
};
}, []);

// 탭 클릭 시 해당 섹션으로 스크롤
const handleTabClick = (tab: "product-info" | "review" | "recommend") => {
const sectionRefMap = {
"product-info": productInfoRef,
review: reviewRef,
recommend: recommendRef,
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

마찬가지로 TabType 타입을 활용해 sectionRefMap에 타입을 명시해 주는 게 어떨까요?

const sectionRefMap: Record<TabType, RefObject<HTMLDivElement>> = {
      "product-info": productInfoRef,
      review: reviewRef,
      recommend: recommendRef,
};

지금은 일반 객체라 키 값에 오타가 나거나, 혹시나 나중에 탭이 추가되었을 때 누락되면 에러가 날 수 있을 것 같아요. 함께 Record 타입을 사용하면 모든 탭 ID가 키로 포함되었는지 강제할 수 있어서 유지보수에 훨씬 안전할 것 같습니당


const targetRef = sectionRefMap[tab];

if (targetRef.current) {
const offsetTop =
targetRef.current.offsetTop - HEADER_HEIGHT - TAB_BAR_HEIGHT;

window.scrollTo({
top: offsetTop,
behavior: "smooth",
});
Comment on lines +62 to +71
Copy link
Copy Markdown
Collaborator

@odukong odukong Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 감지 대상 상단으로 부터 떨어진 Y축의 값을 나타내는 targetRef.current.offsetTop 활용하여 스크롤 되어야 할 값인offsetTop을 구하고 계신데, 이 방식은 추후에 레이아웃이 변경되면 스크롤되는 위치가 틀어지는 이슈가 발생할 수 있다고 해요.

element.offsetTop의 경우, 문서 전체를 기준으로 요소의 top을 가져오는게 아니라 position이 설정된 가장 가까운 부모를 기준으로 top을 계산한다고 합니다.
지금은 부모 요소에 position와 별도의 레이아웃이 따로 설정되어있지 않아 문서 body를 기준으로 계산되어 잘 작동되고 있지만,

<div className={styles.test} style={{ position: "relative", marginTop: "100rem" }}>

위 코드처럼 부모 요소에 position을 지정하고 약간의 레이아웃을 변경하면, 아래와 같이 스크롤 이벤트를 발생시켰을 때는 잘 작동이 되지만, 직접 탭을 클릭해 스크롤 이벤트를 발생시키면 스크롤 위치가 틀어지는 이슈가 발생합니다.

-.Clipchamp.mp4

이러한 문제를 방지하기 위해 getBoundingClientRect().top을 활용하는 방식을 제안드립니당!!
getBoundingClientRect().top은 현재 보고 있는 뷰포트의 최상단에서 해당 요소가 얼마나 떨어져 있는지를 계산하기 때문에 부모 요소와는 무관하게 정확한 값을 구할 수 있다고 해요!
그리고 현재 유저가 스크롤 한 y값(window.scrollY)을 함께 더해주면 레이아웃 구조와는 상관없이 절대적인 위치를 기준으로 탭 스크롤이 가능해질 것 같습니다💛

Suggested change
const offsetTop =
targetRef.current.offsetTop - HEADER_HEIGHT - TAB_BAR_HEIGHT;
window.scrollTo({
top: offsetTop,
behavior: "smooth",
});
const offsetTop =
targetRef.current.getBoundingClientRect().top +
window.scrollY -
HEADER_HEIGHT -
TAB_BAR_HEIGHT;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예시까지 보여주셔서 바로 이해가 됐습니다!!!!! 😍
offsetTop이 문서 전체 기준이 아니라 position이 잡힌 부모 요소 기준이라는 점을 몰랐었는데, 그래서 지금은 부모에 별도 레이아웃이 없어서 우연히 정확해 보였던 거군요...

순간 그럼 IntersectionObserver는 왜 정상이지??를 고민했는데, IntersectionObserver는 실제 DOM의 top 값을 사용하는 게 아니라 브라우저가 계산한 뷰포트에 보이는지 여부만 판단해서 문제 없이 동작했던 거였네요
반면 탭 클릭 시에는 offsetTop 값이 부모 기준으로 계산되니까, 실제 문서에서의 위치와 어긋나서 스크롤이 잘못된 지점으로 이동했던 것이고요 🤔

덕분에 아주 확실히 이해했습니다! 말씀해주신 방향으로 바로 수정하겠습니다 :) 감사합니닷 ㅎㅎㅎ

}
};

return {
productInfoRef,
reviewRef,
recommendRef,
activeTab,
handleTabClick,
};
}