Skip to content

Instantly share code, notes, and snippets.

@adamwathan
Last active March 3, 2022 01:17
Show Gist options
  • Save adamwathan/3b9f3ad1a285a2d1b482769aeb862467 to your computer and use it in GitHub Desktop.
Save adamwathan/3b9f3ad1a285a2d1b482769aeb862467 to your computer and use it in GitHub Desktop.
Tailwind UI Transitions in React

This is a little wrapper component I've put together around react-transition-group that makes it easy to integrate the transition styles we use in Tailwind UI.

Here's what it looks like to use with one of our simple dropdown components for example:

import { useState } from "react";
import Transition from "./Transition.js";

function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div className="relative ...">
      <button type="button" onClick={() => setIsOpen(!isOpen)} className="...">
        Options
      </button>

      <Transition
        show={isOpen}
        enter="transition ease-out duration-100 transform"
        enterFrom="opacity-0 scale-95"
        enterTo="opacity-100 scale-100"
        leave="transition ease-in duration-75 transform"
        leaveFrom="opacity-100 scale-100"
        leaveTo="opacity-0 scale-95"
      >
        <div className="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg">
          <div className="rounded-md bg-white shadow-xs">{/* Snipped */}</div>
        </div>
      </Transition>
    </div>
  );
}

The show prop controls whether the elements are shown or hidden, and the rest of the props are classes that should be added during different phases of the transitions, and map to the comments you see in the component code.

You'll sometimes see examples in Tailwind UI where two things are transitioning at the same time based on the same piece of state. For example, in our sidebar layouts, when you open the sidebar the sidebar itself has to slide in from the left, while a background overlay fades in behind the sidebar. Both of these elements are nested within a shared parent div that needs to be toggled based on whether the sidebar is shown or not.

In these cases, you can use a Transition around the shared parent, passing through the show prop but no class props, and then wrap the transitioning child elements with their own Transition omitting the show prop, and only passing through the class props:

function Sidebar({ isOpen }) {
  return (
    <Transition show={isOpen}>
      {/* Shared parent */}
      <div>
        {/* Background overlay */}
        <Transition
          enter="transition-opacity ease-linear duration-300"
          enterFrom="opacity-0"
          enterTo="opacity-100"
          leave="transition-opacity ease-linear duration-300"
          leaveFrom="opacity-100"
          leaveTo="opacity-0"
        >
          {/* ... */}
        </Transition>

        {/* Sliding sidebar */}
        <Transition
          enter="transition ease-in-out duration-300 transform"
          enterFrom="-translate-x-full"
          enterTo="translate-x-0"
          leave="transition ease-in-out duration-300 transform"
          leaveFrom="translate-x-0"
          leaveTo="-translate-x-full"
        >
          {/* ... */}
        </Transition>
      </div>
    </Transition>
  )
}

The Transition components will automatically work together to make sure the transitions behave the way you'd expect.

Eventually we'll probably publish this as a library you can pull in, but in the mean time just grab a copy of the code and save it in your project with your other components.

import { CSSTransition as ReactCSSTransition } from 'react-transition-group'
import { useRef, useEffect, useContext } from 'react'
const TransitionContext = React.createContext({
parent: {},
})
function useIsInitialRender() {
const isInitialRender = useRef(true)
useEffect(() => {
isInitialRender.current = false
}, [])
return isInitialRender.current
}
function CSSTransition({
show,
enter = '',
enterFrom = '',
enterTo = '',
leave = '',
leaveFrom = '',
leaveTo = '',
appear,
children,
}) {
const enterClasses = enter.split(' ').filter((s) => s.length)
const enterFromClasses = enterFrom.split(' ').filter((s) => s.length)
const enterToClasses = enterTo.split(' ').filter((s) => s.length)
const leaveClasses = leave.split(' ').filter((s) => s.length)
const leaveFromClasses = leaveFrom.split(' ').filter((s) => s.length)
const leaveToClasses = leaveTo.split(' ').filter((s) => s.length)
function addClasses(node, classes) {
classes.length && node.classList.add(...classes)
}
function removeClasses(node, classes) {
classes.length && node.classList.remove(...classes)
}
return (
<ReactCSSTransition
appear={appear}
unmountOnExit
in={show}
addEndListener={(node, done) => {
node.addEventListener('transitionend', done, false)
}}
onEnter={(node) => {
addClasses(node, [...enterClasses, ...enterFromClasses])
}}
onEntering={(node) => {
removeClasses(node, enterFromClasses)
addClasses(node, enterToClasses)
}}
onEntered={(node) => {
removeClasses(node, [...enterToClasses, ...enterClasses])
}}
onExit={(node) => {
addClasses(node, [...leaveClasses, ...leaveFromClasses])
}}
onExiting={(node) => {
removeClasses(node, leaveFromClasses)
addClasses(node, leaveToClasses)
}}
onExited={(node) => {
removeClasses(node, [...leaveToClasses, ...leaveClasses])
}}
>
{children}
</ReactCSSTransition>
)
}
function Transition({ show, appear, ...rest }) {
const { parent } = useContext(TransitionContext)
const isInitialRender = useIsInitialRender()
const isChild = show === undefined
if (isChild) {
return (
<CSSTransition
appear={parent.appear || !parent.isInitialRender}
show={parent.show}
{...rest}
/>
)
}
return (
<TransitionContext.Provider
value={{
parent: {
show,
isInitialRender,
appear,
},
}}
>
<CSSTransition appear={appear} show={show} {...rest} />
</TransitionContext.Provider>
)
}
export default Transition
@vincaslt
Copy link

vincaslt commented Jul 25, 2020

I have a problem with leave transitions being interrupted with parent Transition wrapping child Transition components (like in Dropdown example).
I believe the way this works is parent waits for transition end event in addEndListener to bubble from children to execute unmountOnExit. If some deep child component has a transition that will end before the transitions on component wrapped with Transition, the event listener will be triggered, component will be unmounted and animation will be interrupted. Example of this is a button, that has: focus:shadow-outline-blue transition. If this button was inside of Dropdown or a Modal using the same Transition setup as in the example, the animation would be interrupted when button is clicked (pretty much no animation on leave).

Dirty workaround I came up with:

addEndListener={(done) => {
  nodeRef.current?.addEventListener(
    'transitionend',
    (e) => {
      if (!isChild || e.target === nodeRef.current) {
        done();
      } else if (isChild) {
        e.stopPropagation();
      }
    },
    false
  );
}}

[...]

if (isChild) {
  return (
    <CSSTransition
      appear={parent.appear || !parent.isInitialRender}
      show={parent.show}
      isChild
      {...rest}
    />
  );
}

I know this will eat some transition events, but with some caution it works until there's a real fix.

@turkyden
Copy link

turkyden commented Aug 13, 2020

Updated version for TypeScript based on what @impulse and @EricKit did (Thanks guys 🚀)
I just fixed some of the TypeScript errors and warnings in the function CSSTransition.

import React, { ReactElement, useContext, useEffect, useRef } from 'react';
import { CSSTransition as ReactCSSTransition } from 'react-transition-group';

type TransitionContextProps = {
  parent: {
    show: boolean;
    isInitialRender: boolean;
    appear?: boolean;
  };
};

const TransitionContext = React.createContext<Partial<TransitionContextProps>>({
  parent: {
    show: false,
    isInitialRender: true,
  },
});

function useIsInitialRender(): boolean {
  const isInitialRender = useRef(true);

  useEffect(() => {
    isInitialRender.current = false;
  }, []);

  return isInitialRender.current;
}

interface TransitionProps {
  show: boolean;
  enter?: string;
  enterFrom?: string;
  enterTo?: string;
  leave?: string;
  leaveFrom?: string;
  leaveTo?: string;
  appear?: boolean;
  children: React.ReactNode;
}

type CSSTransitionProps = TransitionProps;

function CSSTransition({
  show,
  enter = '',
  enterFrom = '',
  enterTo = '',
  leave = '',
  leaveFrom = '',
  leaveTo = '',
  appear,
  children,
}: CSSTransitionProps): ReactElement {
  const enterClasses = enter.split(' ').filter((s) => s.length);
  const enterFromClasses = enterFrom.split(' ').filter((s) => s.length);
  const enterToClasses = enterTo.split(' ').filter((s) => s.length);
  const leaveClasses = leave.split(' ').filter((s) => s.length);
  const leaveFromClasses = leaveFrom.split(' ').filter((s) => s.length);
  const leaveToClasses = leaveTo.split(' ').filter((s) => s.length);

  function addClasses(node: HTMLElement, classes: string[]): void {
    if (classes.length) {
      node.classList.add(...classes);
    }
  }

  function removeClasses(node: HTMLElement, classes: string[]): void {
    if (classes.length) {
      node.classList.remove(...classes);
    }
  }

  const noderef = React.useRef<HTMLDivElement>(null);

  const addEndListener = (done: () => void): void => {
    noderef.current?.addEventListener('transitionend', done, false);
  };

  const onEnter = (): void => {
    if (noderef.current) addClasses(noderef.current, [...enterClasses, ...enterFromClasses]);
  };

  const onEntering = (): void => {
    if (noderef.current) {
      removeClasses(noderef.current, enterFromClasses);
      addClasses(noderef.current, enterToClasses);
    }
  };

  const onEntered = (): void => {
    if (noderef.current) removeClasses(noderef.current, [...enterToClasses, ...enterClasses]);
  };

  const onExit = (): void => {
    if (noderef.current) addClasses(noderef.current, [...leaveClasses, ...leaveFromClasses]);
  };

  const onExiting = (): void => {
    if (noderef.current) {
      removeClasses(noderef.current, leaveFromClasses);
      addClasses(noderef.current, leaveToClasses);
    }
  };

  const onExited = (): void => {
    if (noderef.current) removeClasses(noderef.current, [...leaveToClasses, ...leaveClasses]);
  };

  return (
    <ReactCSSTransition
      appear={appear}
      nodeRef={noderef}
      unmountOnExit
      in={show}
      addEndListener={addEndListener}
      onEnter={onEnter}
      onEntering={onEntering}
      onEntered={onEntered}
      onExit={onExit}
      onExiting={onExiting}
      onExited={onExited}
    >
      <div ref={noderef}>{children}</div>
    </ReactCSSTransition>
  );
}

function Transition({ show, appear, ...rest }: TransitionProps): ReactElement {
  const { parent } = useContext(TransitionContext);
  const isInitialRender = useIsInitialRender();
  const isChild = show === undefined;

  if (isChild) {
    return (
      <CSSTransition
        appear={parent ? parent.appear || !parent.isInitialRender : false}
        show={parent?.show ? parent.show : false}
        {...rest}
      />
    );
  }

  return (
    <TransitionContext.Provider
      value={{
        parent: {
          show,
          isInitialRender,
          appear,
        },
      }}
    >
      <CSSTransition appear={appear} show={show} {...rest} />
    </TransitionContext.Provider>
  );
}

export default Transition;

👍 👍 👍 Thank you ! It work >= react-transition-group@4.4.0.

@roderik
Copy link

roderik commented Aug 14, 2020

An update to the last version above to allow you to pass in classNames.

Based on this conversation to make some of the more complex sidebars work (they fail due to the extra div added by Transition and more than one show in nested Transitions) https://discordapp.com/channels/674983035946663957/682241064115109992/743763261207347301

import React, { ReactElement, useContext, useEffect, useRef } from 'react';
import { CSSTransition as ReactCSSTransition } from 'react-transition-group';

interface ITransitionContextProps {
  parent: {
    show: boolean;
    isInitialRender: boolean;
    appear?: boolean;
  };
}

const TransitionContext = React.createContext<Partial<ITransitionContextProps>>({
  parent: {
    show: false,
    isInitialRender: true,
  },
});

function useIsInitialRender(): boolean {
  const isInitialRender = useRef(true);

  useEffect(() => {
    isInitialRender.current = false;
  }, []);

  return isInitialRender.current;
}

interface ITransitionProps {
  show?: boolean;
  enter?: string;
  enterFrom?: string;
  enterTo?: string;
  leave?: string;
  leaveFrom?: string;
  leaveTo?: string;
  appear?: boolean;
  className?: string;
  children: React.ReactNode;
}

type CSSTransitionProps = ITransitionProps;

function CSSTransition({
  show,
  enter = '',
  enterFrom = '',
  enterTo = '',
  leave = '',
  leaveFrom = '',
  leaveTo = '',
  appear,
  className,
  children,
}: CSSTransitionProps): ReactElement {
  const enterClasses = enter.split(' ').filter((s) => s.length);
  const enterFromClasses = enterFrom.split(' ').filter((s) => s.length);
  const enterToClasses = enterTo.split(' ').filter((s) => s.length);
  const leaveClasses = leave.split(' ').filter((s) => s.length);
  const leaveFromClasses = leaveFrom.split(' ').filter((s) => s.length);
  const leaveToClasses = leaveTo.split(' ').filter((s) => s.length);

  function addClasses(node: HTMLElement, classes: string[]): void {
    if (classes.length) {
      node.classList.add(...classes);
    }
  }

  function removeClasses(node: HTMLElement, classes: string[]): void {
    if (classes.length) {
      node.classList.remove(...classes);
    }
  }

  const noderef = React.useRef<HTMLDivElement>(null);

  const addEndListener = (done: () => void): void => {
    noderef.current?.addEventListener('transitionend', done, false);
  };

  const onEnter = (): void => {
    if (noderef.current) {
      addClasses(noderef.current, [...enterClasses, ...enterFromClasses]);
    }
  };

  const onEntering = (): void => {
    if (noderef.current) {
      removeClasses(noderef.current, enterFromClasses);
      addClasses(noderef.current, enterToClasses);
    }
  };

  const onEntered = (): void => {
    if (noderef.current) {
      removeClasses(noderef.current, [...enterToClasses, ...enterClasses]);
    }
  };

  const onExit = (): void => {
    if (noderef.current) {
      addClasses(noderef.current, [...leaveClasses, ...leaveFromClasses]);
    }
  };

  const onExiting = (): void => {
    if (noderef.current) {
      removeClasses(noderef.current, leaveFromClasses);
      addClasses(noderef.current, leaveToClasses);
    }
  };

  const onExited = (): void => {
    if (noderef.current) {
      removeClasses(noderef.current, [...leaveToClasses, ...leaveClasses]);
    }
  };

  return (
    <ReactCSSTransition
      appear={appear}
      nodeRef={noderef}
      unmountOnExit={true}
      in={show}
      addEndListener={addEndListener}
      onEnter={onEnter}
      onEntering={onEntering}
      onEntered={onEntered}
      onExit={onExit}
      onExiting={onExiting}
      onExited={onExited}
    >
      <div className={className} ref={noderef}>
        {children}
      </div>
    </ReactCSSTransition>
  );
}

export function Transition({ show, appear, ...rest }: ITransitionProps): ReactElement {
  const { parent } = useContext(TransitionContext);
  const isInitialRender = useIsInitialRender();
  const isChild = show === undefined;

  if (isChild) {
    return (
      <CSSTransition
        appear={parent ? parent.appear || !parent.isInitialRender : false}
        show={parent?.show ? parent.show : false}
        {...rest}
      />
    );
  }

  return (
    <TransitionContext.Provider
      value={{
        parent: {
          show,
          isInitialRender,
          appear,
        },
      }}
    >
      <CSSTransition appear={appear} show={show} {...rest} />
    </TransitionContext.Provider>
  );
}

Example usage: https://gist.github.com/roderik/fa3546fb30a0dc31c5ef3719dfb96be2

@BigWhale
Copy link

BigWhale commented Sep 5, 2020

I had to make a tiny fix to the ITransitionContextProps because compiler was nagging about show being undefind.

interface ITransitionContextProps {
    parent: {
        show?: boolean;
        isInitialRender: boolean;
        appear?: boolean;
    };
}

Since I'm just starting with TypeScript, I didn't dig deeper to discover a reason for this, I just made show optional. It seems that TransitionContext.Provider needs a default setting somewhere?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment