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

목차
- 개요
- 실제 렌더링 예시
- 전략
- 구현 1. 높이 만큼만 보여주기
- 구현 2. 더보기/접기 버튼 만들기
- 구현 3. 줄 수가 넘어갈 때에만 더보기/접기 버튼 보이기
- CSS 전략의 실패
- 더 나아갈 길
- 참고했던 글
개요
짤막한 리뷰와 같은 글 콘텐츠를 나열할 때 너무 복잡해보이지 않도록 하기 위해 n줄 이상 넘어가는 콘텐츠를 가리고 더보기/접기
버튼을 둘 수 있습니다. 이걸 자연스럽고 적당히 어렵지 않게 React 컴포넌트로 구현하는 게 이번 글의 목표입니다.
아래와 같은 상황은 더보기/접기
가 필요하지 않을 것 같습니다.
- 블로그 글: 하나의 완성된 콘텐츠라면 페이지 하나를 차지해도 괜찮으므로 나열을 한다 해도 내부 콘텐츠는 페이지로 향하는 링크로 간단히 해결됩니다.
- 글이 콘텐츠가 아님: 이미지를 더보기/접기할 일은 없겠죠? 만약 한다고 하면 확대/축소가 될 것 같군요.
- 제한이 엄격히 적용됨: 옛날 트위터처럼 하나의 트윗에 140자 밖에 쓰지 못한다 했을 때는, 콘텐츠를 전부다 보여주면서 띄엄띄엄 나열한다면 부담이 없으므로
더보기/접기
가 불필요합니다. - 제목과 내용이 구분: 제목이 따로 있고
펼치기/접기
같은 느낌이라면detail
과summary
태그를 이용할 수 있습니다. 우리가 대하는 콘텐츠는 그렇게 구분되어 있지 않습니다.
스타일링은 편의상 Tailwind CSS로 진행하겠습니다.
그리고 디자인 상 세부적인 요구사항이 또 있었는데요, 바로 "더보기" 버튼은 외부에 있는 게 아니라 콘텐츠와 함께 자연스럽게 있어야 했습니다. 버튼을 외부로 둔다면 구현이 훨씬 간단해질 수 있을텐데요...

실제 렌더링 예시
아래 더보기
버튼을 눌러보세요!
대통령은 국무총리·국무위원·행정각부의 장 기타 법률이 정하는 공사의 직을 겸할 수 없다. 헌법재판소 재판관의 임기는 6년으로 하며, 법률이 정하는 바에 의하여 연임할 수 있다. 대한민국은 국제평화의 유지에 노력하고 침략적 전쟁을 부인한다. 위원은 탄핵 또는 금고 이상의 형의 선고에 의하지 아니하고는 파면되지 아니한다. 국민의 자유와 권리는 헌법에 열거되지 아니한 이유로 경시되지 아니한다. 중앙선거관리위원회는 법령의 범위안에서 선거관리·국민투표관리 또는 정당사무에 관한 규칙을 제정할 수 있으며, 법률에 저촉되지 아니하는 범위안에서 내부규율에 관한 규칙을 제정할 수 있다.
대통령은 국무총리·국무위원·행정각부의 장 기타 법률이 정하는 공사의 직을 겸할 수 없다. 헌법재판소 재판관의 임기는 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
를 재계산할 수 있도록 했습니다.lineHeight
와lineClamp
로 최대 높이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 (<pref={pRef}className="relative overflow-hidden"style={{ maxHeight: !expanding ? maxHeight : undefined }}>{content}{!expanding ? (<buttonclassName="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>) : (<buttonclassName="text-primary ml-0.5 hover:underline"onClick={handleButtonClick}>접기</button>)}</p>);};
- "더보기" 상태를 저장하고 있기 위해
expanding
state를 만들었습니다. - "더보기" 버튼을 absolute하게 위치하기 위해
p
의 position을relative
로 설정했습니다. - "더보기" 버튼의 배경 색을 자연스럽게 설정해서 버튼이 자연스러워 보이도록 했습니다.
expanding=false
일 때에만maxHeight
가 적용되도록 했습니다.


와~ 이정도만 해도 거의 다 된 거 같아요.
구현 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><pref={pRef}className="relative overflow-hidden"style={{ maxHeight: expanding ? undefined : maxHeight }}>{content}{!isOverflown ? null : !expanding ? (<buttonclassName="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>) : (<buttonclassName="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에 배포해보고 싶네요! (아마 다음 글로 그렇게 진행해보지 않을까 싶습니다... 후후...)
참고했던 글
- 반응형으로 말줄임 CSS + 더보기 버튼 구현하기 by Jiseong님