Skip to content

Instantly share code, notes, and snippets.

@adamwathan
Last active March 3, 2022 01:17
Show Gist options
  • Star 96 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • 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
@andre347
Copy link

Thanks for this! Makes it so much easier to convert AlpineJS into React. However, this doesn't work out of the box. If you bring this file alongside your other React components you need to make sure you are exporting the Transition function from this file.

export default function Transition({...etc

React also needs to be added to this file. On line 1 you want to import it:

import React from 'react'

@adamwathan
Copy link
Author

Updated, thanks @andre347!

@vvo
Copy link

vvo commented Apr 16, 2020

I had to use appear={true} in my case for the animation to kick in when starting with in={true}. For example when using it on a component you just added to the tree like a notification.

@vvo
Copy link

vvo commented Apr 17, 2020

Also, I think there's a bug because the current code will remove the "enterToClasses" when the enter animation is done and will remove the "leaveToClasses" when the exit animation is done. So it should be:

onEntered={(node) => {
  node.classList.remove(...enterClasses);
}}

and

onExited={(node) => {
  node.classList.remove(...leaveClasses);
}}

The bug is not always visible, you can see it if the enterTo opacity is something like opacity-75 instead of 100.

@vvo
Copy link

vvo commented Apr 18, 2020

For anyone reading this and struggling to implement the Sidebar Layout with React, head over the discord channel and find my message about sidebar and layout (not sure it's ok to post the private gist here since this gist might have leaked at other places).

@NSpehler
Copy link

@vvo I'm also facing issues reproducing the Sidebar Layout in React, but I can't find your message anywhere on the Discord server. Could you please post it here? 🙏

@vvo
Copy link

vvo commented Apr 18, 2020

Hey there, it’s inside the react channel in the last messages

@ecklf
Copy link

ecklf commented Apr 24, 2020

TypeScript:

import React, { 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() {
  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) {
  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[]) {
    classes.length && node.classList.add(...classes);
  }

  function removeClasses(node: HTMLElement, classes: string[]) {
    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 }: TransitionProps) {
  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;

@audiolion
Copy link

audiolion commented May 5, 2020

For the TS Variant I would make the TransitionProps for enter/leave variants:

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

Because the function CSSTransition fills in default values if the params aren't provided, indicating that they are optional to the caller.

The original code was changed as well, so the TransitionContextProps should be:

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

@ecklf
Copy link

ecklf commented May 5, 2020

For the TS Variant I would make the TransitionProps for enter/leave variants:

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

Because the function CSSTransition fills in default values if the params aren't provided, indicating that they are optional to the caller.

The original code was changed as well, so the TransitionContextProps should be:

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

Sounds good. The parent in TransitionContext will need to have show and isInitialRender defined then.

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

Applied the changes to the post above.

@Mozart409
Copy link

Mozart409 commented May 6, 2020

Thanks @adamwathan for creating this easy way to import tailwindui animations to react. Quick question, I wanted to update my listed Gatsby Starter with the tailwindui plugin (paste the require statement into the tailwind.config.js) and this transition.js file. I want to make it clear, no HTML, CSS or JS (components, layouts etc…) will be included from tailwindui except this transition.js file. Am I allowed to do that?

@betocmn
Copy link

betocmn commented May 9, 2020

Hey @vvo, I can't find the example on the Discord channel. I'm having issues trying to set this up for the first example from https://tailwindui.com/components/marketing/elements/headers and would love to see a working version of the one you played with if that's ok. Thanks in advance!

@vvo
Copy link

vvo commented May 9, 2020

@betocmn
Copy link

betocmn commented May 10, 2020

Thanks so much, @vvo!

@vvo
Copy link

vvo commented May 10, 2020

For anyone coming here, there's a new Transition component that should solve your issues if any: https://gist.github.com/adamwathan/e0a791aa0419098a7ece70028b2e641e

@EricKit
Copy link

EricKit commented May 11, 2020

I modified the typescript CSSTransition for the new update that added nodeRef to fix an issue with strict mode and depreciations. reactjs/react-transition-group#559

function CSSTransition({
  show,
  enter = '',
  enterFrom = '',
  enterTo = '',
  leave = '',
  leaveFrom = '',
  leaveTo = '',
  appear,
  children,
}: CSSTransitionProps) {
  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[]) {
    classes.length && node.classList.add(...classes);
  }

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

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

  return (
    <ReactCSSTransition
      appear={appear}
      nodeRef={nodeRef}
      unmountOnExit
      in={show}
      addEndListener={(node, done) => {
        nodeRef.current?.addEventListener('transitionend', done, false);
      }}
      onEnter={(node) => {
        nodeRef.current && addClasses(nodeRef.current, [...enterClasses, ...enterFromClasses]);
      }}
      onEntering={(node) => {
        nodeRef.current && removeClasses(nodeRef.current, enterFromClasses);
        nodeRef.current && addClasses(nodeRef.current, enterToClasses);
      }}
      onEntered={(node) => {
        nodeRef.current && removeClasses(nodeRef.current, [...enterToClasses, ...enterClasses]);
      }}
      onExit={(node) => {
        nodeRef.current && addClasses(nodeRef.current, [...leaveClasses, ...leaveFromClasses]);
      }}
      onExiting={(node) => {
        nodeRef.current && removeClasses(nodeRef.current, leaveFromClasses);
        nodeRef.current && addClasses(nodeRef.current, leaveToClasses);
      }}
      onExited={(node) => {
        nodeRef.current && removeClasses(nodeRef.current, [...leaveToClasses, ...leaveClasses]);
      }}
    >
      <div ref={nodeRef}>{children}</div>
    </ReactCSSTransition>
  );
}

@acuellarh
Copy link

Hey @vvo, I can't find the example on the Discord channel. I'm having issues trying to set this up for the first example from https://tailwindui.com/components/marketing/elements/headers and would love to see a working version of the one you played with if that's ok. Thanks in advance!

Hi dear, Did you manage to run the example? If you did it with React, can you share it with me? Thank you

@rodricarranza
Copy link

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;

@tobeycodes
Copy link

FYI Wrapping the ReactCSSTransition child component with <div ref={noderef}>{children}</div> breaks several layouts such as the sidebar layout.

@EricKit
Copy link

EricKit commented Jun 6, 2020

@rodricarranza Thanks! I saw that that the new @types created some issues about 15 days ago.

@Akumzy
Copy link

Akumzy commented Jun 7, 2020

Made show prop options based on @rodricarranza update

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: Boolean(show),
          isInitialRender,
          appear,
        },
      }}
    >
      <CSSTransition appear={appear} show={show} {...rest} />
    </TransitionContext.Provider>
  )
}

export default Transition

@stevecastaneda
Copy link

stevecastaneda commented Jun 10, 2020

Has anyone been able to create a CSSTransition component that animates both in and out when nested inside a TransitionGroup? I can't seem to get it to work. Exit works fine, but not entering transitions.

https://codesandbox.io/s/frosty-clarke-4dzl1

Here's the component I created...it's used inside a <TransitionGroup> component.

import { Transition as ReactTransition } from "react-transition-group";

interface TransitionProps {
  in?: boolean;
  timeout: number;
  enter?: string;
  enterFrom?: string;
  enterTo?: string;
  leave?: string;
  leaveFrom?: string;
  leaveTo?: string;
  children: ReactNode;
}

function addClasses(classes: string[], ref: React.RefObject<HTMLDivElement>) {
  console.log("Adding classes:", classes, ref);
  ref.current?.classList.add(...classes);
}

function removeClasses(classes: string[], ref: React.RefObject<HTMLDivElement>) {
  console.log("Removing classes:", classes, ref);
  ref.current?.classList.remove(...classes);
}

export function CSSTransition(props: TransitionProps) {
  const { enter, enterFrom, enterTo, leave, leaveFrom, leaveTo } = props;
  const nodeRef = React.useRef<HTMLDivElement>(null);

  const enterClasses = splitClasses(enter);
  const enterFromClasses = splitClasses(enterFrom);
  const enterToClasses = splitClasses(enterTo);
  const leaveClasses = splitClasses(leave);
  const leaveFromClasses = splitClasses(leaveFrom);
  const leaveToClasses = splitClasses(leaveTo);

  return (
    <ReactTransition
      in={props.in}
      nodeRef={nodeRef}
      timeout={props.timeout}
      unmountOnExit
      onEnter={() => {
        console.log("onEnter", nodeRef);
        addClasses([...enterClasses, ...enterFromClasses], nodeRef);
      }}
      onEntering={() => {
        console.log("onEntering", nodeRef);
        removeClasses(enterFromClasses, nodeRef);
        addClasses(enterToClasses, nodeRef);
      }}
      onEntered={() => {
        console.log("onEntered", nodeRef);
        removeClasses([...enterToClasses, ...enterClasses], nodeRef);
      }}
      onExit={() => {
        console.log("onExit", nodeRef);
        addClasses([...leaveClasses, ...leaveFromClasses], nodeRef);
      }}
      onExiting={() => {
        console.log("onExiting", nodeRef);
        removeClasses(leaveFromClasses, nodeRef);
        addClasses(leaveToClasses, nodeRef);
      }}
      onExited={() => {
        console.log("onExited", nodeRef);
        removeClasses([...leaveToClasses, ...leaveClasses], nodeRef);
      }}
    >
      <div ref={nodeRef}>{props.children}</div>
    </ReactTransition>
  );
}

function splitClasses(string: string = ""): string[] {
  return string.split(" ").filter((s) => s.length);
}

@dazuaz
Copy link

dazuaz commented Jun 19, 2020

Should not wrap a div with the ref element, just create the ref and pass it down to the component, that way you have full control.

import { useRef, useEffect, useContext } from "react"
import { CSSTransition as ReactCSSTransition } from "react-transition-group"

const TransitionContext = React.createContext({
  parent: {},
})

function useIsInitialRender() {
  const isInitialRender = useRef(true)
  useEffect(() => {
    isInitialRender.current = false
  }, [])
  return isInitialRender.current
}

function CSSTransition({
  nodeRef,
  show,
  enter = "",
  enterFrom = "",
  enterTo = "",
  leave = "",
  leaveFrom = "",
  leaveTo = "",
  appear,
  children,
  ...rest
}) {
  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(classes) {
    classes.length && nodeRef.current.classList.add(...classes)
  }

  function removeClasses(classes) {
    classes.length && nodeRef.current.classList.remove(...classes)
  }

  return (
    <ReactCSSTransition
      nodeRef={nodeRef}
      appear={appear}
      unmountOnExit
      in={show}
      addEndListener={(done) => {
        addEventListener("transitionend", done, false)
      }}
      onEnter={() => {
        addClasses([...enterClasses, ...enterFromClasses])
      }}
      onEntering={() => {
        removeClasses(enterFromClasses)
        addClasses(enterToClasses)
      }}
      onEntered={() => {
        removeClasses([...enterToClasses, ...enterClasses])
      }}
      onExit={() => {
        addClasses([...leaveClasses, ...leaveFromClasses])
      }}
      onExiting={() => {
        removeClasses(leaveFromClasses)
        addClasses(leaveToClasses)
      }}
      onExited={() => {
        removeClasses([...leaveToClasses, ...leaveClasses])
      }}
      {...rest}>
      {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

@reverie
Copy link

reverie commented Jun 30, 2020

Yet another paste :). Here's my Transition.tsx. Incorporated a few bits from above. It works with react-transition-group@4.4.1. Note that I believe there's an issue with the latter's typing for the timeout property...

// Based on https://gist.github.com/adamwathan/3b9f3ad1a285a2d1b482769aeb862467

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

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

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

function useIsInitialRender() {
  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;
}

function CSSTransition({
  show,
  enter = '',
  enterFrom = '',
  enterTo = '',
  leave = '',
  leaveFrom = '',
  leaveTo = '',
  appear,
  children,
}: TransitionProps) {
  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);
    }
  }

  return (
    <ReactCSSTransition
      appear={appear}
      unmountOnExit
      in={show}
      timeout={(undefined as unknown) as any}
      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 }: TransitionProps) {
  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: Boolean(show),
          isInitialRender,
          appear,
        },
      }}
    >
      <CSSTransition appear={appear} show={show} {...rest} />
    </TransitionContext.Provider>
  );
}

export default Transition;

@g7s
Copy link

g7s commented Jul 11, 2020

Many elements in TailwindUI have transitions (buttons, links etc). In a hypothetical scenario I have two Transition components wrapped with another Transition that handles show. Now lets say that I click a button and it changes the show to false. A leave transition starts happening with duration, say, 1000ms (for a dramatic effect). The button I clicked, though, had a transition duration of, say, 100ms so it dispatched the transitionend event before the Transition component has finished leaving. Because the transitionend event bubbles, the transitionend listener, attached to the Transition component node, fires and calls done which has the effect of ending the transition prematurely.

Just a heads up.

@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