Creating Autoresizeble textarea with hidden input technique

uramanovich

Ulad Ramanovich

Posted on October 14, 2023

Creating Autoresizeble textarea with hidden input technique

The unusual HTML input behaviour

In the modern UI a lot of time you can see unusual HTML input behaviour. One of these unusual behaviour is autoresize. By autoresize I mean changing the input height while the user typing. In this article let’s talk about creating autoresizable textarea in React.

There is a few different way ho you can achieve this:

  • You can use <div contenteditable="true"> and manipulate with the editable div input. This is not the usual approach and can be done for very specific cases (you can see it for example in almost all online regexp editors when they highlight text inside input). It requires replication full input behaviour because div works completely differently.
  • You can change input height while the user typing. This can simplest way to implement expected results but this approach is not the best if you want to handle more cases.
  • You can create one more hidden input. Hide it and do all calculations with this “hidden” input and just apply the expected height on the “visible” input. This is a good approach from my perspective as with this approach you can handle a lot of cases.

Let’s talk more about the “hidden input” approach and try to create an autoresize textarea.

Let's create our own resizeable textarea

For this project, I use typescript + react + emotion css. You can replicate this code without any problem with any framework or plain css + js.

Let's start with the creation of 2 textareas and add ref as we need it in the feature.

const Textarea = (props: InputHTMLAttributes<HTMLTextAreaElement>) => {
    const innerTextAreaRef = useRef<HTMLTextAreaElement>(null);
    const shadowTextAreaRef = useRef<HTMLTextAreaElement>(null);

    reutrn (
        <textarea {...props} ref={innerTextAreaRef} />
        {/* We use only neccecery fields to our shadow input */}
        <textarea
            ref={shadowTextAreaRef}
            placeholder={props.placeholder}
            value={props.value}
            className={props.className}
        />
    )
}
Enter fullscreen mode Exit fullscreen mode

Now we have 2 elements and let's add some styles and make 1 textarea hidden.

import styled from "@emotion/styled";

const StyledInput = styled.textarea`
    vertical-align: top;
    resize: vertical;
    overflow: hidden;
    resize: none;
    width: 100%;
`;

const StyledShadowTextarea = styled(StyledInput)`
    // Visibility needed to hide the extra text area on iPads
    visibility: hidden;
    // Remove from the content flow
    position: absolute;
    // Ignore the scrollbar width
    overflow: hidden;
    height: 0;
    top: 0;
    left: 0;

    // Create a new layer, increase the isolation of the computed values
    transform: translateZ(0);

    // Rewrite all horizontal paddings
    // We do so because we don't need them for height calculation
    padding-top: 0 !important;
    padding-bottom: 0 !important;
`;

// ...code

    reutrn (
        <StyledInput {...props} ref={innerTextAreaRef} />
        <StyledShadowTextarea
            placeholder={props.placeholder}
            value={props.value}
            className={props.className}
            ref={shadowTextAreaRef}
            {/* Add more props for accesability */} 
            aria-hidden
            readOnly
            tabIndex={-1}
        />
    )
Enter fullscreen mode Exit fullscreen mode

Now we have a simple textarea and you can drag it but this is not what we expected because you can set only a static amount of rows and it can't change dynamically based on the input. To do so let's start with creating simple functionality to change the height of the textarea.

// Helper
    const getStyleValue = (value: string) => {
      return parseInt(value, 10) || 0;
    };

    const adjustRowHeight = useCallback(() => {
      if (!innerTextAreaRef.current || !shadowTextAreaRef.current) {
        return;
      }

      const shadowInput = shadowTextAreaRef.current;
      const input = innerTextAreaRef.current;
      const computedStyle = window.getComputedStyle(input);

      // Adjust shadow input width
      shadowInput.style.width = computedStyle.width;

      const paddings =
        getStyleValue(computedStyle.paddingTop) +
        getStyleValue(computedStyle.paddingBottom);
      const border =
        getStyleValue(computedStyle.borderBottomWidth) +
        getStyleValue(computedStyle.borderTopWidth);
      const innerHeight = shadowInput.scrollHeight;

      // Set height to visible input
      input.style.height = `${innerHeight + paddings + border}px`;
    }, []);
Enter fullscreen mode Exit fullscreen mode

Now we need to trigger this function every time when value changes. Sounds like a good task to use useEffect.

    useEffect(() => {
      adjustRowHeight();
    }, [props.value, adjustRowHeight]);
Enter fullscreen mode Exit fullscreen mode

Now we have a simple implementation that dynamically changes our input based on user input.

You can simply implement this with one input (almost, there is one problem if you try to change height for a calculated element but this is topic for a one more article). But let's start to create more real-world examples when we want to limit the amount of rows.

Let's think first about how we can calculate and restrict the number of rows. If we want to restrict amount of rows this is equal to restricting height size of the input and if input goes over this height we enable default scrollable behaviour with overflow: hidden.

Let's start by adding the new props for our component.

type BasicProps = InputHTMLAttributes<HTMLTextAreaElement>;

export type TextareaProps = BasicProps & {
  maxRows?: number;
};
Enter fullscreen mode Exit fullscreen mode

And now let's change our adjustRowHeight function:

const adjustRowHeight = useCallback(() => {
    const shadowInput = shadowTextAreaRef.current;
    // ...previous code here

    // Measure 1 row height by any symbol to the input
    shadowInput.value = 'x';
    const singleRowHeight = shadowInput.scrollHeight;

    // Calculate outer height
    let outerHeight = innerHeight;

    if (maxRows) {
        const maxHeight = maxRows * singleRowHeight;
        // If outer input height higher than max input we use max input
        outerHeight = Math.min(maxHeight, outerHeight);
    }

    const isInputScrollable = Math.abs(innerHeight - outerHeight) >= 1;

    input.style.overflow = isInputScrollable ? 'auto' : 'hidden';

    // Set height to visible input
    input.style.height = `${outerHeight + paddings + border}px`;
}, [maxRows]);
Enter fullscreen mode Exit fullscreen mode

This brings up one interesting problem. If we change overflow that means scrollbar appears with overflow: hidden. This creates available width change for the input inner width and gives us a weird "content jump". We have two strategies to deal with this:

  • Hide scrollbar (or create a custom which not change the width)
  • Always keep space for the scrollbar Both solutions have downsides and you should choose based on your UI requirements. Let's for simplicity just hide the scrollbar.
const StyledInput = styled(Input)`
    // ... previous code

    &::-webkit-scrollbar {
        display: none;
    }
`;

// or if you want to show it and keep space
const StyledInput = styled(Input)`
    // ... previous code

    // scrollbar-gutter property reserve space for the scrollbar.
    scrollbar-gutter: stable;
`;
Enter fullscreen mode Exit fullscreen mode

Now we are almost done with our functionality. Let's assume we want to add more functionality and now we want to support minimum rows count. With shadow input we can easily add this.

export type TextareaProps = BasicProps & {
  maxRows?: number;
  minRows?: number;
};

// ...pervious code
const adjustRowHeight = useCallback(() => {
    // ...pervious code

    if (minRows) {
        const minHeight = minRows * singleRowHeight;
        outerHeight = Math.max(minHeight, outerHeight);
    }

    // calculate max rows and set it to the input
}, [maxRows, minRows])
Enter fullscreen mode Exit fullscreen mode

Now we have a fully working autoresizeble textarea!
We can make some improvements to the component and add passing ref support.

export const Textarea = forwardRef<HTMLTextAreaElement | null, TextareaProps>(
  (
    { minRows, maxRows, ...props },
    ref: Ref<HTMLTextAreaElement | null> | null,
  ) => {
        // Magic happens here. Now your component support ref and you can trigger events like "onFocus" from parent component
        useImperativeHandle(ref, () => innerTextAreaRef.current);

        // other component code
  }
)
Enter fullscreen mode Exit fullscreen mode

Full code you can find in the codesandbox.

Conclusion

The default browser input is limited by browser API and to be able to create beautiful and smooth UI sometimes you need to use "hacks". Every hack has its own disadvantage and can bring unusual problems every time you go beyond the Browser API.

In this article, we tried to deal with the most common problems you can face when working with custom teaxtarea. We focused not on the "theoretical problem" but we solved real-world problems and built autoresizable components in the pure react (and a little bit of css in js). Now your component is ready to use and only you need to do is to apply your styles.

Adding a library can limit you and confuse you when you are faced with unusual problems but with your solution where you fully understand the code you can deal with it and expand the solution in the way you want. You can always use the Material UI autoresize component (which use this technic too!) and not spend time on your own implementation but it always bring restriction for your project.

With the "shadow input" technique you can do more with your input. It shouldn't hurt the user experience or performance and also remove browser restrictions from your input.

Now you know more about how input works and probably discovered new css/react features!

Links

💖 💪 🙅 🚩
uramanovich
Ulad Ramanovich

Posted on October 14, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related