글 목록으로 이동

봄가을 블로그

기술2025년 02월 15일--views

[React] 텍스트 콘텐츠 더보기/접기 UI 깔끔하게 구현하기

콘텐츠 요소가 이리저리 많을 때 흔히 사용하는 더보기/접기 UI를 React로 깔끔하게 구현해봅니다.

더보기/접기 하는 UI
더보기/접기 하는 UI

목차

개요

짤막한 리뷰와 같은 글 콘텐츠를 나열할 때 너무 복잡해보이지 않도록 하기 위해 n줄 이상 넘어가는 콘텐츠를 가리고 더보기/접기 버튼을 둘 수 있습니다. 이걸 자연스럽고 적당히 어렵지 않게 React 컴포넌트로 구현하는 게 이번 글의 목표입니다.

아래와 같은 상황은 더보기/접기가 필요하지 않을 것 같습니다.

  • 블로그 글: 하나의 완성된 콘텐츠라면 페이지 하나를 차지해도 괜찮으므로 나열을 한다 해도 내부 콘텐츠는 페이지로 향하는 링크로 간단히 해결됩니다.
  • 글이 콘텐츠가 아님: 이미지를 더보기/접기할 일은 없겠죠? 만약 한다고 하면 확대/축소가 될 것 같군요.
  • 제한이 엄격히 적용됨: 옛날 트위터처럼 하나의 트윗에 140자 밖에 쓰지 못한다 했을 때는, 콘텐츠를 전부다 보여주면서 띄엄띄엄 나열한다면 부담이 없으므로 더보기/접기가 불필요합니다.
  • 제목과 내용이 구분: 제목이 따로 있고 펼치기/접기같은 느낌이라면 detailsummary 태그를 이용할 수 있습니다. 우리가 대하는 콘텐츠는 그렇게 구분되어 있지 않습니다.

스타일링은 편의상 Tailwind CSS로 진행하겠습니다.

그리고 디자인 상 세부적인 요구사항이 또 있었는데요, 바로 "더보기" 버튼은 외부에 있는 게 아니라 콘텐츠와 함께 자연스럽게 있어야 했습니다. 버튼을 외부로 둔다면 구현이 훨씬 간단해질 수 있을텐데요...

더보기/접기 구현 예시(이미지)
더보기/접기 구현 예제(이미지)

실제 렌더링 예시

아래 더보기 버튼을 눌러보세요!

대통령은 국무총리·국무위원·행정각부의 장 기타 법률이 정하는 공사의 직을 겸할 수 없다. 헌법재판소 재판관의 임기는 6년으로 하며, 법률이 정하는 바에 의하여 연임할 수 있다. 대한민국은 국제평화의 유지에 노력하고 침략적 전쟁을 부인한다. 위원은 탄핵 또는 금고 이상의 형의 선고에 의하지 아니하고는 파면되지 아니한다. 국민의 자유와 권리는 헌법에 열거되지 아니한 이유로 경시되지 아니한다. 중앙선거관리위원회는 법령의 범위안에서 선거관리·국민투표관리 또는 정당사무에 관한 규칙을 제정할 수 있으며, 법률에 저촉되지 아니하는 범위안에서 내부규율에 관한 규칙을 제정할 수 있다.

소스코드를 확인해보셔도 좋을 것 같아요! 그럼 이제 구현 전략을 간단히 살펴보죠.

전략

구현 방법은 크게 자바스크립트를 이용한 방법과 그렇지 않은 방법이 있습니다.

  • 자바스크립트: 한 줄의 높이를 동적으로 알아내서 원하는 줄 수만 보여주고 상태에 따라 버튼을 잘 위치시킵니다.
  • CSS 활용: -webkit-box 와 line-clamp 기능, 그리고 버튼을 float 요소로 보여줍니다. 이 방법은 불행히도 잘 안됐는데요, 뒤에서 설명합니다.

이 글에서는 자바스크립트를 주로 활용하여 구현합니다.

구현 1. 높이 만큼만 보여주기

일단 컴포넌트를 만들어봅시다.

export type ExpandableTextProps = {
content: string;
lineClamp?: number;
};
export const ExpandableText: React.FC<ExpandableTextProps> = ({
content,
lineClamp = 2,
}) => {
return <div>{content}</div>;
};
  • 컴포넌트의 이름은 ExpandableText로 지었습니다.
  • content를 Props로 받습니다. 타입은 단순히 문자열로 합니다. React Component로 받을 수도 있지만, 그렇게 되면 "줄 수"라는 개념이 좀 모호해집니다. 예를 들어 한 줄짜리 p가 세 개 있는데 각각 margin-bottom이 있다면? ... 흠. 생각하기 싫네요. 문제를 간단히 가져갑시다.
  • lineClamp를 Props로 받습니다. 텍스트 콘텐츠를 몇 줄까지 보여줄지 외부에서 제어할 수 있도록 합니다.

자, 이제 lineClamp 만큼 줄을 보여주도록 합시다.

export type ExpandableTextProps = {
content: string;
lineClamp?: number;
};
export const ExpandableText: React.FC<ExpandableTextProps> = ({
content,
lineClamp = 2,
}) => {
const pRef = useRef<HTMLParagraphElement>(null);
const [lineHeight, setLineHeight] = useState<number | null>(null);
useEffect(() => {
const observer = new ResizeObserver(() => {
if (!pRef.current) {
return;
}
setLineHeight(parseFloat(getComputedStyle(pRef.current).lineHeight));
});
if (pRef.current) {
observer.observe(pRef.current);
}
return () => {
observer.disconnect();
};
}, []);
const maxHeight = lineHeight ? lineHeight * lineClamp : undefined;
return (
<p ref={pRef} className="overflow-hidden" style={{ maxHeight }}>
{content}
</p>
);
};
  • useRef로 콘텐츠 p의 DOM 속성에 접근할 수 있도록 합니다.
  • 한 줄의 높이를 저장해 놓기 위해 lineHeight를 state로 둡니다.
  • getComputedStyle(pRef.current)로 한 줄의 높이를 계산하고 lineHeight에 저장합니다.
  • ResizeObserver를 이용하여 콘텐츠의 사이즈가 바뀔 때마다 lineHeight를 재계산할 수 있도록 했습니다.
  • lineHeightlineClamp로 최대 높이 maxHeight를 계산합니다.
  • 콘텐츠는 overflow:hidden로 두고 max-height를 설정합니다.

이제 아래와 같이 보입니다.

줄 수 만큼만 보이는 텍스트 콘텐츠
줄 수 만큼만 보이는 텍스트 콘텐츠

(저 높이는 실제 높이는 아닙니다. 왜 그런지는 모르겠서요~~)

구현 2. 더보기/접기 버튼 만들기

이제 더보기/접기 버튼을 실제로 보여줍시다!

export const ExpandableText: React.FC<ExpandableTextProps> = ({
content,
lineClamp = 2,
}) => {
const pRef = useRef<HTMLParagraphElement>(null);
const [lineHeight, setLineHeight] = useState<number | null>(null);
const [expanding, setExpanding] = useState(false);
useEffect(() => {
// 중략
}, []);
const maxHeight = lineHeight ? lineHeight * lineClamp : undefined;
const handleButtonClick = () => {
setExpanding((prev) => !prev);
};
return (
<p
ref={pRef}
className="relative overflow-hidden"
style={{ maxHeight: !expanding ? maxHeight : undefined }}
>
{content}
{!expanding ? (
<button
className="z-1 text-primary absolute bottom-0 right-0 block bg-gradient-to-r from-transparent via-white via-40% to-white pl-8 hover:underline"
onClick={handleButtonClick}
>
더보기
</button>
) : (
<button
className="text-primary ml-0.5 hover:underline"
onClick={handleButtonClick}
>
접기
</button>
)}
</p>
);
};
  • "더보기" 상태를 저장하고 있기 위해 expanding state를 만들었습니다.
  • "더보기" 버튼을 absolute하게 위치하기 위해 p의 position을 relative로 설정했습니다.
  • "더보기" 버튼의 배경 색을 자연스럽게 설정해서 버튼이 자연스러워 보이도록 했습니다.
  • expanding=false일 때에만 maxHeight가 적용되도록 했습니다.
ExpandableText - expanding:false 상태
ExpandableText - expanding:false 상태
ExpandableText - expanding:true 상태
ExpandableText - expanding:true 상태

와~ 이정도만 해도 거의 다 된 거 같아요.

구현 3. 줄 수가 넘어갈 때에만 더보기/접기 버튼 보이기

지금까지 구현의 문제점은 높이가 낮더라도 아래와 같이 무조건 더보기/접기 버튼이 보인다는 겁니다.

콘텐츠가 한 줄임에도 불구하고 더보기 버튼이 보이고 있음
콘텐츠가 한 줄임에도 불구하고 더보기 버튼이 보이고 있음

overflow 여부를 한번 체크해봅시다.

export const ExpandableText: React.FC<ExpandableTextProps> = ({
content,
lineClamp = 2,
}) => {
const pRef = useRef<HTMLParagraphElement>(null);
const [lineHeight, setLineHeight] = useState<number | null>(null);
const [expanding, setExpanding] = useState(false);
const originalRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
// 중략
}, []);
const maxHeight = lineHeight ? lineHeight * lineClamp : undefined;
const isOverflown =
originalRef.current?.scrollHeight &&
maxHeight &&
originalRef.current?.scrollHeight > maxHeight;
const handleButtonClick = () => {
setExpanding((prev) => !prev);
};
return (
<>
<p className="h-0 overflow-hidden" ref={originalRef}>
{content}
</p>
<p
ref={pRef}
className="relative overflow-hidden"
style={{ maxHeight: expanding ? undefined : maxHeight }}
>
{content}
{!isOverflown ? null : !expanding ? (
<button
className="z-1 text-primary absolute bottom-0 right-0 block bg-gradient-to-r from-transparent via-white via-40% to-white pl-8 hover:underline"
onClick={handleButtonClick}
>
더보기
</button>
) : (
<button
className="text-primary ml-0.5 hover:underline"
onClick={handleButtonClick}
>
접기
</button>
)}
</p>
</>
);
};
  • originalRef 를 만들었습니다. 실제 높이를 정확히 체크하기 위함입니다. "접기" 버튼이 pRef에 포함되어 있어서 엣지 케이스가 생길 수 있는 위험을 없앴습니다.
  • originalRef는 실제로 노출되지 않도록 높이를 0으로 설정하고 overflow: hidden 처리했습니다.
  • 내부 콘텐츠의 높이를 계산하기 위하여 scrollHeight 를 활용했습니다. 이 값을 maxHeight와 비교하여 더보기/접기 버튼을 보일지 말지 결정합니다.

이제 의도대로 잘 동작함을 볼 수 있습니다.

긴 글(위)일 때만 더보기 버튼이 보이고 짧은 글(아래)은 더보기 버튼이 안보인다!
긴 글(위)일 때만 더보기 버튼이 보이고 짧은 글(아래)은 더보기 버튼이 안보인다!

🎉 고생하셨습니다!!

CSS 전략의 실패

처음에는 CSS 전략을 시도했습니다. -webkit-box의 존재를 알고 있었고, 이를 이용하여 완벽하게 우리의 요구사항을 충족시키는 글을 발견했기 때문이죠.

CSS 전략은 대략 다음과 같이 진행합니다. 우선 컨테이너를 아래와 같이 둡니다.

.container {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2; /* 동적으로 계산 */
}

그리고 더보기 버튼 또한 container에 포함시키고 다음과 같이 스타일을 설정합니다.

.show-more-button {
float: right;
margin-top: 42px; /* 동적으로 계산 */
shape-outside: border-box;
}

그러나! Apple Safari 계열 브라우저에서 제대로 보이지 않는 문제가 있었습니다. float 요소가 절대로 -webkit-box 안에 들어가지가 않더라고요. 대충 아래 처럼 이상하게 보였습니다.

askldfjaslk dflas dfkla sdlf askldf alsfkl

그래서 포.기. 했습니다.

더 나아갈 길

이 컴포넌트를 아래와 같은 방법으로 더 발전시켜나갈 수 있습니다.

  • Easing Gradient: 단순 linear-gradient로 하는 게 아니라 시각적으로 더 예쁜 gradient를 구현합니다.
  • 스타일 커스터마이징: 좀 더 범용적인 컴포넌트로 만드려면 콘텐츠나 버튼의 스타일을 외부에서 수정할 수 있는 수단이 필요합니다. Tailwind 식으로 한다면 외부로부터 className을 넘겨받고 내부에서 tailwind-merge로 합치는 방식으로 진행할 수 있습니다.
  • 애니메이션: 높이 값을 auto가 아니라 명시적으로 관리하고 있으므로 애니메이션을 주기가 좋습니다. React Spring 같은 걸 잘 이용하면 어떻게 되지 않을까 싶습니다. ㅎㅎ
  • 꼼꼼한 리렌더링: ref가 초기화되는 타이밍에 따라 더보기 버튼이 보이지 않을 수 있는 문제가 있을 수 있습니다. ref가 초기화되는 타이밍을 좀 더 자세히 공부해오고 확실히 해보겠습니다. ㅠㅠ
  • DOM 구조 유의: 겉으로 봤을 땐 p 두 개가 생기는데요, 이로 인하여 예상치 못한 사이드이펙이 있을 수 있습니다. 예를 들어 flex 레이아웃이라면 영향이 있을 수 있습니다. div로 감싸면 왠만한 문제는 해결될 것 같아요.

저는 개인적으로 이 컴포넌트를 npm에 배포해보고 싶네요! (아마 다음 글로 그렇게 진행해보지 않을까 싶습니다... 후후...)

참고했던 글