Textarea with Line Numbers in React
This article demonstrates how to create a re-usuable custom textarea component in React with line numbers. I used TypeScript and Styled-Components but feel free to use whichever tools you're comfortable with. A complete code can be found in this CodeSandbox.
Step 1: Create controlled textarea
If you wish control the value outside of the component, pass in value and onValueChange as props.
type TextareaProps = { value: string; onValueChange: (value: string) => void; placeholder?: string; name?: string;};
export const Textarea = ({ value, onValueChange, placeholder, name,}: TextareaProps) => { const handleTextareaChange = ( event: React.ChangeEvent<HTMLTextAreaElement> ) => { onValueChange(event.target.value); };
return ( <textarea name={name} onChange={handleTextareaChange} placeholder={placeholder} value={value} wrap="off" /> );};Step 2: Add numbers to each line
Here comes the challenging part of this component. I want to add numOfLines as props so this component remains as reusuable and as flexible as possible. I then want to convert numOfLines into an array so it can iterate and render on the UI.
I used useMemo to count the number of lines in value and to create an array because this component will constantly re-render as user types into the textarea. By using useMemo, the function will only execute when the dependencies change.
The newly created array (linesArr) should watch for lineCount and be added to the array as user adds line breaks.
type TextareaProps = { // ... previous props numOfLines: number;};
export const Textarea = ({ // ... previous props numOfLines,}: TextareaProps) => { // count the number of lines in `value` const lineCount = useMemo(() => value.split("\n").length, [value]); // create array const linesArr = useMemo( () => Array.from({ length: Math.max(numOfLines, lineCount) }, (_, i) => i + 1), [lineCount, numOfLines] );
return ( <div> <div> {linesArr.map((count) => ( <div key={count}>{count}</div> ))} </div> <textarea /> </div> );};Step 3: Style the components
const StyledTextareaWrapper = styled.div` border: 1px solid grey; border-radius: 2px; width: 500px; height: 220px;`;
const sharedStyle = css` margin: 0; padding: 10px 0; height: 200px; border-radius: 0; resize: none; outline: none; font-family: monospace; font-size: 16px; line-height: 1.2; &:focus-visible { outline: none; }`;
const StyledTextarea = styled.textarea` ${sharedStyle}; padding-left: 3.5rem; width: calc(100% - 3.5rem); border: none; &::placeholder { color: grey; }`;
const StyledNumbers = styled.div` ${sharedStyle}; display: flex; flex-direction: column; overflow-y: hidden; text-align: right; box-shadow: none; position: absolute; color: grey; border: none; background-color: lightgrey; padding: 10px; width: 1.5rem;`;
// ...export const Textarea = ({}: TextareaProps) => { // ... return ( <StyledTextareaWrapper> <StyledNumbers> {linesArr.map((count) => ( <div key={count}>{count}</div> ))} </StyledNumbers> <StyledTextarea /> </StyledTextareaWrapper> );};Step 4: Synchronize the position of textarea and the numbers wrapper on scroll
What I want to achieve in this step is when <StyledTextarea /> scrolls, <StyledNumbers /> scrolls simultaneously. To acheive this, I make use of useRef. Here's the implementation:
// 1. Define refsconst lineCounterRef = useRef<HTMLDivElement>(null);const textareaRef = useRef<HTMLTextAreaElement>(null);
// 3. Add onScroll handlerconst handleTextareaScroll = () => { if (lineCounterRef.current && textareaRef.current) { lineCounterRef.current.scrollTop = textareaRef.current.scrollTop; }};
// 2. Place refs in componentsreturn ( <StyledTextareaWrapper> <StyledNumbers ref={lineCounterRef}> {linesArr.map((count) => ( <div key={count}>{count}</div> ))} </StyledNumbers> <StyledTextarea ref={textareaRef} onScroll={handleTextareaScroll} /> </StyledTextareaWrapper>);Final Code
import React, { useMemo, useRef } from "react";import styled, { css } from "styled-components";
type TextareaProps = { value: string; numOfLines: number; onValueChange: (value: string) => void; placeholder?: string; name?: string;};
const StyledTextareaWrapper = styled.div` border: 1px solid grey; border-radius: 2px; width: 500px; height: 220px;`;
const sharedStyle = css` margin: 0; padding: 10px 0; height: 200px; border-radius: 0; resize: none; outline: none; font-family: monospace; font-size: 16px; line-height: 1.2; &:focus-visible { outline: none; }`;
const StyledTextarea = styled.textarea` ${sharedStyle} padding-left: 3.5rem; width: calc(100% - 3.5rem); border: none; &::placeholder { color: grey; }`;
const StyledNumbers = styled.div` ${sharedStyle} display: flex; flex-direction: column; overflow-y: hidden; text-align: right; box-shadow: none; position: absolute; color: grey; border: none; background-color: lightgrey; padding: 10px; width: 1.5rem;`;
const StyledNumber = styled.div<{ active: boolean }>` color: ${(props) => (props.active ? "blue" : "inherit")};`;
export const Textarea = ({ value, numOfLines, onValueChange, placeholder = "Enter Message", name,}: TextareaProps) => { const lineCount = useMemo(() => value.split("\n").length, [value]); const linesArr = useMemo( () => Array.from({ length: Math.max(numOfLines, lineCount) }, (_, i) => i + 1), [lineCount, numOfLines] );
const lineCounterRef = useRef<HTMLDivElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleTextareaChange = ( event: React.ChangeEvent<HTMLTextAreaElement> ) => { onValueChange(event.target.value); };
const handleTextareaScroll = () => { if (lineCounterRef.current && textareaRef.current) { lineCounterRef.current.scrollTop = textareaRef.current.scrollTop; } };
return ( <StyledTextareaWrapper> <StyledNumbers ref={lineCounterRef}> {linesArr.map((count) => ( <StyledNumber active={count <= lineCount} key={count}> {count} </StyledNumber> ))} </StyledNumbers> <StyledTextarea name={name} onChange={handleTextareaChange} onScroll={handleTextareaScroll} placeholder={placeholder} ref={textareaRef} value={value} wrap="off" /> </StyledTextareaWrapper> );};Using this component
import React, { useState } from "react";import { Textarea } from "./Textarea";
export default function App() { const [value, setValue] = useState("");
return ( <div> <Textarea name="test-textarea" value={value} onValueChange={(value: string) => setValue(value)} numOfLines={10} /> </div> );}