Textarea의 rows를 깔끔하게 수정해보자

쭌로그·2025년 12월 28일

1.서론

"textarea 영역이 3줄까지 늘어나고 그 이상은 스크롤이 되었으면 좋겠어요!"
UI 요구사항 중 자주 요청받는 기능 중 하나인 특정 입력창 늘리기입니다.
실무에서는 주로 useRef를 사용하는 방식을 주로 활용합니다. 하지만 이 외에도 canvas를 활용하는 방법을 학습하여 기록하고자합니다!

2. useRef 방식

import { ChangeEvent, useRef, useEffect } from "react";
import cx from "./cx"
import { measureLines } from "@/service/util"

const TextBox3 = () => {
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const cloneRef = useRef<HTMLTextAreaElement>(null);
  useEffect(() => {
    const elem = textareaRef.current;
    const cloneElem = cloneRef.current;
    if(!elem || !cloneElem) return;
    const handlerInput = () => {

      const val = elem.value;
      cloneElem.value = val;
      //최소 3줄, 최대 15줄
      elem.rows = Math.min(Math.max(Math.ceil(cloneElem.scrollHeight / cloneElem.clientHeight), 3), 15);
    }
    elem.rows = 3;
    elem?.addEventListener('input', handlerInput);
    return () => {
      elem?.removeEventListener('input', handlerInput);
    }
  },[])
  return (
    <>
      <h3>
        #1<sub>controlled. clone elem</sub>
      </h3>
      <div className={cx('container')}>
        <textarea className={cx('clone')} ref={cloneRef} readOnly />
        <textarea  className={cx('textarea')} ref={textareaRef} />
      </div>
    </> 
  )
}

export default TextBox3;
.TextBoxes {
  .container {
    width: 50vw;
    position: relative;
  }

  .textBox {
    box-sizing: border-box;
    width: 100%;
    resize: none;
    overflow-y: scroll;
  }

  .clone {
    box-sizing: border-box;
    width: 100%;
    resize: none;
    overflow-y: scroll;

    position: absolute;
    top: 0;
    left: -9999;
    z-index: -1;
    visibility: hidden;
    opacity: 0;
  }
}
  1. useEffect에서 input 이벤트를 할당하여 입력 이벤트 발생 시 우선적으로 clone 요소에 값을 입력합니다.
  2. scrollHeight, clientHeight를 통해 현재 라인 수를 찾을 수 있습니다.
  3. 해당 결과값을 통해 textarea의 row로 할당하면 자연스럽게 요소의 높이를 조절할 수 있습니다.

3. Canvas 방식

import { ChangeEvent, useRef, useEffect } from "react";
import cx from "./cx"
import { measureLines } from "@/service/util"

const TextBox2 = () => {
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  useEffect(() => {
    const elem = textareaRef.current;
    if(!elem) return;
    const handlerInput = () => {

      const val = elem.value;
      const lines = Math.min(Math.min(measureLines(elem, val),3), 15);
      elem.rows = lines;
    }
    elem.rows = 3;
    elem?.addEventListener('input', handlerInput);
    return () => {
      elem?.removeEventListener('input', handlerInput);
    }
  },[])
  return (
    <>
      <h3>
        #2<sub>uncontrolled. canvas</sub>
      </h3>
      <div className={cx('container')}>
        <textarea ref={textareaRef}  className={cx('textarea')} />
      </div>
    </> 
  )
}

export default TextBox2;
const measureLines = (elem: HTMLTextAreaElement, val: string) => {
  // textarea 요소나 값이 없으면 줄 수는 0
  if (!elem || !val) return 0;

  const canvas = document.createElement("canvas");
  const context = canvas.getContext("2d");

  // textarea의 실제 렌더링 스타일을 가져옴
  const style = window.getComputedStyle(elem);

  // canvas context를 얻지 못한 경우 안전하게 0 반환
  if (!context) return 0;

  // textarea와 동일한 폰트 스타일을 canvas에 적용
  // → 실제 화면에서의 텍스트 너비와 최대한 동일하게 측정하기 위함
  context.font = `${style.getPropertyValue('font-size')} ${style.getPropertyValue('font-family')}`;

  // 개행(\n) 기준으로 문자열을 분리한 뒤,
  // 각 줄이 textarea 너비를 기준으로 몇 줄로 wrapping 되는지 계산
  const measuredLines = val.split('\n').reduce((totalLines, currentLine) => {
    // 현재 줄을 한 줄로 쭉 나열했을 때의 픽셀 너비 측정
    const textWidth = context.measureText(currentLine).width;

    // textarea 너비로 나누어 실제 줄 수 계산
    const wrappedLineCount = Math.max(
      Math.ceil(textWidth / elem.offsetWidth),
      1
    );

    // 전체 줄 수에 현재 줄에서 발생한 줄 수를 누적
    return totalLines + wrappedLineCount;
  }, 0);

  // 최종 계산된 총 줄 수 반환
  return measuredLines;
};

Canvas를 활용한 방식은 DOM에 값을 추가하지 않기 때문에 Reflow를 방지할 수 있다는 장점이 있습니다. 또한 레이아웃 기반의 계산이 아닌 canvas를 통해 계산하기 때문에 이벤트 비용적으로도 효율적입니다.

하지만 ${style.getPropertyValue('font-size')} ${style.getPropertyValue('font-family')}; 에서 요소의 글자 크기와 폰트가 잘못 할당 될 경우, 화면의 요소와 다르게 계산되어 줄넘김이 이상하게 동작할 수 있습니다.

참조

인프런 - [React / VanillaJS] UI 요소 직접 만들기 Part 1 - 정재남님

profile
매일 발전하는 프론트엔드 개발자

0개의 댓글