tons of smiley, thumbs up and heart emojis
Photo by Anthony Hortin on Unsplash

Reacting with Emojis

Building a custom React hook to place emojis on any element.

5 min read

In this article, we’re building a custom React hook to attach an emoji to any element. This exercise is a fun way to see practical uses of event listeners and the most common React hooks, in less than 100 lines of Javascript.

If your app needs emoji-based reactions to things like posts or comments, then this article is for you!

Getting Started

First let’s define the basic behavior of our hook, useEmoji.

When a user clicks on an element, for example an image, we’ll show a menu with a few emojis to choose from. After one is chosen, it appears wherever the click occurred.

The hook itself takes a reference to an element and returns a menu, all emojis attached to that element, and some basic style properties.

We should allow for most types of HTML elements to be used with the hook and we’ll need access the DOM attributes of those elements to know where they are on the page. This can be done easily with useRef.

Two state values are needed to track where the menu and each emoji should appear. For this, useState will do the job.

To respond to a click, a useEffect adds an event listener to the page and we’ll use that event to determine whether or not the element is clicked.

Finally, create a component named Container. This is where we’ll test our progress.


import { useRef } from "react";
import { useEmoji } from "./useEmoji";
import "./styles.css";

export const Container = () => {
  const titleRef = useRef();

  const { menu, emoji, styles } = useEmoji(titleRef);
	
	return (
		<div className="title">
	    <h1 ref={titleRef} style={styles}>
	      This is a title
	      {menu}
	      {emoji}
	    </h1>
	  </div>
	);
}

Writing the Hook

First add a new file named useEmoji.js.

We’ll start by initializing two state values with useState, including menuCoordinates to track the position of the menu and items to manage the list of selected emoji stickers.


import { useState, useEffect, useRef } from "react";

const EMOJI = ["😀", "👍", "😨", "💯", "😈"];

export const useEmoji = element => {
  const [menuCoordinates, setMenuCoordinates] = useState();
  const [items, setItems] = useState([]);
  const menuRef = useRef();
  const eventRef = useRef();
  const hasMenuCoordinates = typeof menuCoordinates !== "undefined";

  return {};
}

Additionally, we create two refs using useRef: menuRef will reference the menu element, while eventRef stores the click event listener.

There is one other constant named menuCoordinates that tracks whether we have menu coordinates in state or not.

Our hook has a handful of pre-defined emoji but you can substitute these with whatever you want.

The core functionality of the hook centers around event handling, so let’s add a click event.


useEffect(() => {
  if (eventRef.current) return;

  eventRef.current = window.addEventListener("click", handleClick);

  return () => {
    removeEventListener("click", eventRef.current);
  };
}, [element, eventRef]);

We save the event to the eventRef we created earlier so it can be removed when necessary, as well as prevent multiple events from being added.

Now that we have the event listener defined, add a new function named handleClick.

This function determines if the menu was clicked, and where the click was in relation to the trigger element using the getBoundingClientRect() method.

With the clientX and clientY from the click event and the X and Y coordinates of the trigger element, we can derive values for offsetLeft and offsetTop which we'll use for the menu positioning.


const handleClick = e => {
  const menuElWasClicked = menuRef?.current?.contains(e.target);
  const { x, y } = element?.current?.getBoundingClientRect();

  const offsetLeft = e.clientX - x;
  const offsetTop = e.clientY - y;
};

Finally, we need to update the menuCoordinates state . If menuElWasClicked is true, then the state is updated with X and Y coordinates, otherwise if the menu is already open (menuRef?.current is not undefined) and it wasn’t clicked on, we set the coordinate state to undefined.


const handleClick = e => {
  ...

  if (menuRef?.current && !menuElWasClicked) {
    setMenuCoordinates(undefined);
    return;
  } else if (!element?.current.contains(e.target)) {
    // Return if the element we’re tracking has not even been clicked. 
    return;
  }

  setMenuCoordinates({
    x: offsetLeft,
    y: offsetTop,
  });
};

Next let's add the ability to select an emoji from the menu.

Create a new function named handleSelection.


const handleSelection = emoji => {
  setItems(prevState => [
    ...prevState,
    {
      emoji,
      coordinates: {
        ...menuCoordinates,
      },
    },
  ]);

  setMenuCoordinates(undefined);
};

The handleSelection function adds the selected emoji to the items state, along with the menu coordinates. It also closes the menu by resetting the menuCoordinates state.

All that’s remaining for our useEmoji hook is to return the HTML for the menu and emojis.

If we have X and Y coordinates in state, then we know we have a menu to place at those coordinates.


export const useEmoji = element => {
  ...

  const { x, y } = menuCoordinates ?? {};

  const menu = !hasMenuCoordinates ? null : (
	  <div
	    ref={menuRef}
	    className="menu"
	    style={{ left: `${x}px`, top: `${y}px` }}
	  >
	    {EMOJI.map((item, i) => (
	      <button key={i} type="button" onClick={() => handleSelection(item)}>
	        {item}
	      </button>
	    ))}
	  </div>
  );
}

Next we have the HTML for each of the emoji stickers. These are positioned at the exact spot that the click event occurred.


const emoji = items.map((item, i) => (
  <span
    key={i}
    className="emoji-sticker"
    aria-hidden="true"
    style={{
      left: `${item.coordinates.x}px`,
      top: `${item.coordinates.y}px`,
    }}
  >
    {item.emoji}
  </span>
));

Now be sure to update the hook to return the constants we just created.


export const useEmoji = element => {
  ...
  ...

  return {
    menu,
    emoji,
  };
}

One last thing the hook should do is return some style properties for the trigger element. This adds relative positioning to the element to keep the menu and emojis positioned relative to that element, and updates the mouse cursor to make it more obvious that the trigger element can be clicked.


return {
  menu,
  emoji,
  styles: {
    position: "relative",
    cursor: "pointer",
  },
};

These styles are then applied to the trigger element.


<h1 ref={titleRef} style={styles}>
  This is a title
  {menu}
  {emoji}
</h1>

The ideal way to apply this CSS would probably be to use regular CSS classes instead of inline styles. Returning the CSS this way from the hook is just an alternative.

Styling

The CSS that accompanies our hook is relatively straightforward and there are only a few parts. We won’t go into too much detail.

We’ll first style the menu and the button for each emoji option.


.menu {
  background: #fff;
  border: 1px solid #eee;
  max-width: 200px;
  width: 100%;
  margin-left: -100px;
  margin-top: -55px;
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px;
  z-index: 1;
  opacity: 0;
  transform: scale(0.8) translateY(30px);
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  border-radius: 8px;
  animation: animateIn 0.24s 10ms cubic-bezier(0, 1.57, 0.96, 1.12) forwards;
}

.menu button {
  background: none;
  border: none;
  margin: 5px;
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  transition: transform 0.2s ease;

  &:hover {
    transform: scale(1.5);
  }
}

Notice that we’re using some negative margins above. This is meant to adjust the element to be exactly above where the user clicked, moving it left and upwards about half of the menu height and width.

Now for the emoji sticker.


.emoji-sticker {
  position: absolute;
  font-size: 26px;
  margin-left: -13px;
  margin-top: -13px;
  line-height: 1;
  opacity: 0;
  transform: scale(0.8) translateY(5px);
  animation: animateIn 0.2s 10ms cubic-bezier(0, 1.57, 0.96, 1.12) forwards;
}

This is again slightly adjusted left and upwards by about half of the height and width.

And finally we have the animation keyframe for both the menu and the emoji items.


@keyframes animateIn {
  100% {
    transform: translateY(0);
    transform: scale(1);
    opacity: 1;
  }
}

This animation automatically runs when an emoji or menu are added to the DOM, and because it only defines the end state of the animation (100%), any properties that are defined will transition to the ones in the keyframe. For example:

  • scale(0.8)scale(1)
  • translateY(5px or 30px)translateY(0)
  • opacity(0)opacity(1)

What’s Next?

With our custom React hook complete, a number of other additions could be made.

  • The emojis we used were hardcoded. Add more to choose from and make them dynamic with a service like Emoji API
  • Add the ability to move or delete individual emoji
  • Leverage localStorage to persist each item at the position it was added
  • Update the logic to reposition the emojis at the edge of an element, so that they don't cover any content