Add a text or image overlay to a <video> element

We design our SDKs for web and mobile to give developers as much flexibility as possible when building video calls. For example, you can drop a ready-to-use video chat into an existing page with a few lines of code using our prebuilt UI, or build a completely custom call from scratch with the Daily call object.

One thing Daily developers often want to do is overlay text (like a participant’s name) or small images (muted state indicators or logos) on top of a video element. This post walks through how to do that!

Person in Daily call points to their name at the top left corner of the video stream
It took me more time to take this screenshot with one hand than it took me to add my name.

First, we’ll go over the CSS to position one element on top of another. Then, we’ll add those CSS properties to Paul’s Daily React app.

Arrange and stack elements with CSS

We’ll set position and z-index properties to arrange our elements.

position gives us control of how an element will sit in the overall layout of the page. When the property isn't set, every block-level HTML element appears on a new line [0]. We don’t want that! We specifically want our name tag directly on top of and overlapping our video container. Where the name tag goes depends on the video's position.

To set up this dependent relationship, we set our video's position property to relative. Then, we can arrange any child elements, in our case our name tag, in relation to it by setting their position property to absolute.

To see this in action, experiment with removing position:relative from the .parent-container class in this codepen:

Our boxes’ top, bottom, right, and left properties offset them relative to .parent-container.  

With the dependent relationship established, it's time to move on to stacking elements. To do that, we'll need the z-index property. Because we set position properties, we can make use of z-index to stack our elements. The higher the z-index number, the closer to the screen the element will be. Swap the .red-box and .green-box z-index values to see what I mean.

We now know how to arrange child elements in relation to their parents using position, and how to stack them with z-index. We’re ready to take those concepts over to our React demo, but first let’s look at how we can get participant names from the Daily call object.

Passing participant names as props in React

The Daily call object keeps track of our call state, meaning important information about the meeting. This includes details like other participants (e.g. their audio and video tracks and user_name) and the things they do on the call (e.g. muting their mic or leaving)[1]. The call object also provides methods for interacting with the meeting.

In our demo app, we map the Daily call object state to a corresponding component state called callItems in callState.js. Each call item represents a participant, and contains their audio and video tracks, along with a boolean state indicator about whether or not their call is loading. To also track participant names, we'll add participantName to each call item.

const initialCallState = {
 callItems: {
   local: {
     isLoading: true,
     audioTrack: null,
     videoTrack: null,
     participantName: '',
   },
 },
 clickAllowTimeoutFired: false,
 camOrMicError: null,
 fatalError: null,
};

We need to add participantName to our getCallItems function as well. This function loops over the call object to populate our callItems.

function getCallItems(participants, prevCallItems) {
 let callItems = { ...initialCallState.callItems }; // Ensure we *always* have a local participant
 for (const [id, participant] of Object.entries(participants)) {
   // Here we assume that a participant will join with audio/video enabled.
   // This assumption lets us show a "loading" state before we receive audio/video tracks.
   // This may not be true for all apps, but the call object doesn't yet support distinguishing
   // between cases where audio/video are missing because they're still loading or muted.
   const hasLoaded = prevCallItems[id] && !prevCallItems[id].isLoading;
   const missingTracks = !(participant.audioTrack || participant.videoTrack);
   callItems[id] = {
     isLoading: !hasLoaded && missingTracks,
     audioTrack: participant.audioTrack,
     videoTrack: participant.videoTrack,
     participantName: participant.user_name ? participant.user_name : 'Guest',
   };
   if (participant.screenVideoTrack || participant.screenAudioTrack) {
     callItems[id + '-screen'] = {
       isLoading: false,
       videoTrack: participant.screenVideoTrack,
       audioTrack: participant.screenAudioTrack,
     };
   }
 }
 return callItems;
}

getCallItems gets called in Call.js [2]. It then passes the callItems as props via the getTiles function  to <Tile>, the component that displays each participant. We’ll add participantName to the list of props:

export default function Call() {
// 
// Lots of other things happen here! See our demo for full code.
// 
function getTiles() {
   let largeTiles = [];
   let smallTiles = [];
   Object.entries(callState.callItems).forEach(([id, callItem]) => {
     const isLarge =
       isScreenShare(id) ||
       (!isLocal(id) && !containsScreenShare(callState.callItems));
     const tile = (
       <Tile
         key={id}
         videoTrack={callItem.videoTrack}
         audioTrack={callItem.audioTrack}
         isLocalPerson={isLocal(id)}
         isLarge={isLarge}
         isLoading={callItem.isLoading}
         participantName={callItem.participantName}
         onClick={
           isLocal(id)
             ? null
             : () => {
                 sendHello(id);
               }
         }
       />
     );
     if (isLarge) {
       largeTiles.push(tile);
     } else {
       smallTiles.push(tile);
     }
   });
   return [largeTiles, smallTiles];
 }
 
 const [largeTiles, smallTiles] = getTiles();
 
return (
   <div className="call">
     <div className="large-tiles">
       {
         !message
           ? largeTiles
           : null /* Avoid showing large tiles to make room for the message */
       }
     </div>
     <div className="small-tiles">{smallTiles}</div>
     {message && (
       <CallMessage
         header={message.header}
         detail={message.detail}
         isError={message.isError}
       />
     )}
   </div>
 );
}

Now, in Tile.js, we display the name:

// Imports and such 
export default function Tile(props) {
// More code 
function getParticipantName() {
   return (
     props.participantName && (
       <div className="participant-name">{props.participantName}</div>
     )
   );
 }
 
 return (
   <div>
     <div className={getClassNames()} onClick={props.onClick}>
       <div className="background" />
       {getLoadingComponent()}
       {getVideoComponent()}
       {getAudioComponent()}
       {getParticipantName()}
     </div>
   </div>
 );
}

And style it using familiar CSS in Tile.css, with our container tiles set to relative positioning and our video streams and name tags set to absolute:

.tile.small {
 width: 200px;
 margin: 0 10px;
 position: relative;
}
 
.tile.large {
 position: relative;
 margin: 2px;
}
 
.tile video {
 width: 100%;
 position: absolute;
 top: 0px;
 z-index: 1;
}
 
.participant-name {
 padding: 5px 5px;
 position: absolute;
 background: #ffffff;
 font-family: 'Helvetica Neue';
 font-style: normal;
 font-weight: normal;
 font-size: 1rem;
 line-height: 13px;
 text-align: center;
 color: #4a4a4a;
 top: 0;
 left: 0;
 z-index: 10;
}

And there you have it!

If you have any questions or feedback about this post, please email me any time at kimberlee@daily.co. Or, if you’re looking to explore even more ways to customize Daily calls, explore our docs.

[0] This is not the case for inline elements.

[1] A participant’s user_name can be set in a few different ways. It can be passed as a property to the DailyIframe, or set with a meeting token.

[2] More specifically, any time there’s a change to participants on the call, Call.js dispatches an action to a reducer that updates state via getCallItems.


Never miss a story

Get the latest direct to your inbox.