reactjsformsanimationtailwind-css

How to prevent text flicker during input-to-span transition animation tailwind react


I'm implementing an expandable input field that transforms into a span when losing focus. The input expands on focus and shrinks when converting back to a span. While the width animation works correctly, I'm experiencing an unwanted flickering effect in the text content during the transition.

import React, { useState, useRef, useEffect } from 'react'

// Simple className merger function to replace cn()
const cn = (...classes: (string | undefined)[]) => {
  return classes.filter(Boolean).join(' ')
}

interface EditableProps {
  className?: string
  inputClassName?: string
  inlineTextClassName?: string
  placeholder?: string
  maxLength?: number
  isBold?: boolean
  onFocusChange?: (focused: boolean) => void
  inlineText?: string
}

export default function Editable({
  className,
  inputClassName,
  inlineTextClassName,
  placeholder = 'Enter your text here...',
  maxLength = 50,
  isBold = false,
  onFocusChange,
  inlineText = 'inline text'
}: EditableProps) {
  const [text, setText] = useState(placeholder)
  const [isFocused, setIsFocused] = useState(false)
  const inputRef = useRef<HTMLInputElement>(null)
  const containerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (inputRef.current && !inputRef.current.contains(event.target as Node)) {
        setIsFocused(false)
        onFocusChange?.(false)
      }
    }
    document.addEventListener('mousedown', handleClickOutside)
    return () => document.removeEventListener('mousedown', handleClickOutside)
  }, [onFocusChange])

  const handleFocus = () => {
    setIsFocused(true)
    onFocusChange?.(true)
    setTimeout(() => inputRef.current?.select(), 0)
  }

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value.slice(0, maxLength))
  }

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter') inputRef.current?.blur()
  }

  return (
    <div className={cn('flex items-center w-full', className)}>
      <div 
        ref={containerRef}
        className={cn(
          'transition-[flex] duration-300 ease-in-out',
          isFocused ? 'flex-1' : 'flex-initial'
        )}
      >
        {isFocused ? (
          <div className="flex items-center bg-gray-100 rounded-md w-full">
            <input
              ref={inputRef}
              value={text}
              onChange={handleChange}
              onBlur={() => setIsFocused(false)}
              onKeyDown={handleKeyDown}
              className={cn(
                'input-ghost focus:outline-none px-2 py-1 border-none bg-gray-100 rounded-md w-full',
                isBold ? 'font-bold' : 'font-normal',
                inputClassName
              )}
              style={{ fontWeight: isBold ? 'bold' : 'normal' }}
            />
            <div className="flex-shrink-0 px-2 text-sm text-gray-400">
              {text.length}/{maxLength}
            </div>
          </div>
        ) : (
          <span
            onClick={handleFocus}
            className={cn(
              'cursor-pointer hover:opacity-80 px-2 py-1 inline-block',
              isBold ? 'font-bold' : 'font-normal',
              inputClassName
            )}
          >
            {text}
          </span>
        )}
      </div>
      <span className={cn('ml-2 text-sm text-gray-500 flex-shrink-0', inlineTextClassName)}>{inlineText}</span>
    </div>
  )
}

enter image description here

Current Behavior:

Expected Behavior:

How can I eliminate the text flickering effect while maintaining the smooth width animation during the input-to-span transition?


Solution

  • It seems the cause of the flicker is due to the properties you are transitioning. Namely, flex-basis, where you toggle between auto (implicitly from flex-initial) and 0% (from flex-1).

    Instead, it sounds like you'd want to have the width stop/start at the width of the <span>. If so, consider transitioning and changing flex-grow only:

    const { useState, useRef, useEffect } = React;
    
    // Simple className merger function to replace cn()
    const cn = (...classes) => {
      return classes.filter(Boolean).join(' ')
    }
    
    function Editable({
      className,
      inputClassName,
      inlineTextClassName,
      placeholder = 'Enter your text here...',
      maxLength = 50,
      isBold = false,
      onFocusChange,
      inlineText = 'inline text'
    }) {
      const [text, setText] = useState(placeholder)
      const [isFocused, setIsFocused] = useState(false)
      const inputRef = useRef(null)
      const containerRef = useRef(null)
    
      useEffect(() => {
        const handleClickOutside = (event) => {
          if (inputRef.current && !inputRef.current.contains(event.target)) {
            setIsFocused(false)
            onFocusChange(false)
          }
        }
        document.addEventListener('mousedown', handleClickOutside)
        return () => document.removeEventListener('mousedown', handleClickOutside)
      }, [onFocusChange])
    
      const handleFocus = () => {
        setIsFocused(true)
        onFocusChange(true)
        setTimeout(() => inputRef.current.select(), 0)
      }
    
      const handleChange = (e) => {
        setText(e.target.value.slice(0, maxLength))
      }
    
      const handleKeyDown = (e) => {
        if (e.key === 'Enter') inputRef.current.blur()
      }
    
      return (
        <div className={cn('flex items-center w-full', className)}>
          <div 
            ref={containerRef}
            className={cn(
              'transition-[flex-grow] duration-300 ease-in-out',
              isFocused ? 'grow' : 'flex-initial'
            )}
          >
            {isFocused ? (
              <div className="flex items-center bg-gray-100 rounded-md w-full">
                <input
                  ref={inputRef}
                  value={text}
                  onChange={handleChange}
                  onBlur={() => setIsFocused(false)}
                  onKeyDown={handleKeyDown}
                  className={cn(
                    'input-ghost focus:outline-none px-2 py-1 border-none bg-gray-100 rounded-md w-full',
                    isBold ? 'font-bold' : 'font-normal',
                    inputClassName
                  )}
                  style={{ fontWeight: isBold ? 'bold' : 'normal' }}
                />
                <div className="flex-shrink-0 px-2 text-sm text-gray-400">
                  {text.length}/{maxLength}
                </div>
              </div>
            ) : (
              <span
                onClick={handleFocus}
                className={cn(
                  'cursor-pointer hover:opacity-80 px-2 py-1 inline-block',
                  isBold ? 'font-bold' : 'font-normal',
                  inputClassName
                )}
              >
                {text}
              </span>
            )}
          </div>
          <span className={cn('ml-2 text-sm text-gray-500 flex-shrink-0', inlineTextClassName)}>{inlineText}</span>
        </div>
      )
    }
    
    ReactDOM.createRoot(document.getElementById('app')).render(<Editable onFocusChange={() => {}} />);
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js" integrity="sha512-QVs8Lo43F9lSuBykadDb0oSXDL/BbZ588urWVCRwSIoewQv/Ewg1f84mK3U790bZ0FfhFa1YSQUmIhG+pIRKeg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js" integrity="sha512-6a1107rTlA4gYpgHAqbwLAtxmWipBdJFcq8y5S/aTge3Bp+VAklABm2LO+Kg51vOWR9JMZq1Ovjl5tpluNpTeQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdn.tailwindcss.com/3.4.15"></script>
    
    <div id="app"></div>