Building a command palette with Tailwind CSS, React, and Headless UI

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
hey what's up everybody we've just added a combo box component to the headless ui library and i thought that would be pretty cool to build a comment palette with it [Music] here's a demo of what we're going to build so i can toggle my command palette with the keyboard shortcut command k and then i can search for projects and i can navigate with the arrow keys and if i select one project i will be taken to this project page one of the key aspects of comment palettes is they are keyboard first they're a power user tool for folks who want to access things really quickly without leaving the keyboard let me show you some real world example of comment palettes here i am in figma and i can open the comment palette with command p and then search and you can see as i type the results get updated i can navigate with the arrow keys and press enter or escape to get out of there if i open the developer tools in chrome this time i'll use shift command p and we have this comment palette coming up so i can search for disable to disable javascript or other things and once again i can navigate with the arrow keys press enter or escape one more example is vs code so if i type command p i can start searching for settings for example and you get the id i can navigate with the arrow keys i can select or i can escape [Music] alright we're going to use this figma design to build our comment palette and let's take a look at our starting point so we have this existing project management app which is built with tailwind css and next.js we're using tailwind ui components and since on this template we have things like these icons this search field and this drop down menu we have a few extra dependencies installed on the project so of course we're using tailwind css but we're also using the react version of headless ui the react version of heroicons and we're using the firstbody forms plugin for telling css we are going to build our command palette in its own component which actually already exists with some placeholder text right now it's only outputting a styled paragraph and we're actually not even rendering this component anywhere in our application at this point since we want our command palette to work everywhere on every page in the project we will go in the layout.js component which is shared across all pages and here we'll import this component and we'll insert that right at the start of the layout rendered jsx and while we're here let's right away pass some data to this component through a project's prop that data would typically come from an api or database but for the sake of this video we're just using some static data we have an array of projects and at the top where i import the team's data i will also import the project's data okay so now we're rendering our command palette components in our layout and here it is up there we don't just want to display a comment palette like this at all times and it's very common that comment palettes are displayed in a modal window now models and dialogues are deceptively complicated to implement properly fortunately headless ui comes with a dialogue component that makes this really easy to implement it automatically handles things like keyboard navigation focus trapping window scroll locking and more for you that is going to really help us with the implementation of our command palette we are back in our command palette component and so i will first import this dialog component from headless ui and remember i already have this package installed if you did not you'd need to npm install at headless ui react in your project i won't do that here since it's already installed okay and to start with i will simply wrap this component in a dialog component now heads up this is going to break the site since the dialog component needs an open prop and an on close prop we get to decide when we want the dialog to be open or close and then we pass this information to the dialog component and it takes care of all the rest so here let's add an open prop and i'll set that to true for now and then on close will be a function which role is to close the dialogue all we need here is a single piece of local state and i will import the use state hook from react so we can create that piece of state i will create a const is open which is our state value and then a setter for that value that we'll call set is open and we'll use the use state hook here and set the default value to true so now i can replace true here with is open and all we need to do to close the dialog is to set ease open to false and actually the dialog component in headless ui is intelligent enough to know what to do with the state setter so we can just pass set is open here and it will know what to do with it to close the model alright so check this out now our dialog is open but i have many ways to close it i can hit the escape key and it's gone i can click outside of this dialog so if i click here it'll close it as well all right so the logic and behavior is already working now let's apply some styles to this dialog to make it look like a modal window [Music] i will add some utility classes to the dialog component directly and before i do that i'll just remove all the classes except the background color on our placeholder element just so we don't get confused these were just here to display a placeholder on top of everything right at the top we want the dialog to use the entire space of the viewport and seen on top of everything so i'll use the position fixed class and to make sure it spans across the entire viewport i will use inset 0 which will set the top right bottom and left property to zero right now you can't see any difference since it's transparent but if i were to add bg purple 600 you can see our dialog is definitely covering the entire viewport right let me remove that class and let's add a little bit of padding p 4 just so that our modal window doesn't touch the edges of the viewport and i want to move it a bit further away from the top so i could go pt-20 or pt-40 but it really depends on the height of the screen here and i kind of want the top of my model window to be about a quarter from the top so 25 percent from the top of the viewport and to do that i can use an arbitrary value here of 25 viewport height units perfect and one more important thing i need to do since our dialog is fixed is to have a class of overflow y auto which will make the content scroll vertically if it's higher than the viewport height otherwise we couldn't access the content below the viewport okay i think that's a good start for the dialog itself now i want to start this dialog window inside it so let's move down to this div and first i want to add a max width with max w and we'll go with extra large alright and let's center it with mx auto nice doesn't look too problematic right now but let's see what happens when i change the background of this window to white yeah so now it becomes kind of blended with the background and it's obvious that we need some contrast going between the modal window itself and its background so of course we can add a border and a shadow to these elements but something that is quite common with modal windows is to have an overlay between the model window and the rest of the website with some level of transparency this is so common that the dialog component actually provides its own dialog overlay and we're going to need to style it with classes and here once again i will go with fixed and inset 0 so that it covers the entire viewport and since we won't be able to see that i will also add a background color of bg gray 500 and yep we definitely have an overlay i think we should add some transparency to it so i will modify my background color of gray 500 by adding an opacity of 75 nice that looks great but you can see that our model window is actually now behind our overlay we have a little stacking issue here and since our overlay has the position of fixed we need to give a position else then static to our window i'll use relative here so that it comes back on top ah very nice it's starting to look like a model window now if we look at a figma design we want some rounded corners and also a nice soft shadow in the background we will go with rounded extra large nice and let's add the shadow of 2xl sweet and if i zoom on the edge of our box here you can see that there is a subtle border here along the edge and we're going to use a ring utility with a very low opacity here to achieve that let's add a class of ring one for the width ring black for the color so you can see this black outline now which is actually a box shadow and we will reduce the opacity to five percent with slash five so it's extremely subtle but it does add a little bit of depth alright so with this we have a really good foundation to build a command palette instead of this placeholder text this comment palette will have a search section and then a results section and we're going to use headless ui's combobox component which provides all the pcs that we need to build that sort of ui [Music] at the top where i import the dialog component i will also import the combo box component and our combobox component here will be actually this markup that we already have styled a little bit so i will replace this div with the combo box component and once again heads up i will break things as you can see the combo box is being rendered as a fragment which actually disappears from the dom and we're trying to have a class name attribute on this element that actually doesn't exist in the dom an easy fix here is to use the as prop which allows us to choose what elements a component should render as and here since we've replaced a div i will go as div all right and it's working just like before but now we have access to a ton of great features since we're using headless ui's combobox component we're not gonna handle that just yet but i will pass an unchanged prop and basically what we wanna do here is when a user selects one of the options inside the combo box we want to navigate that user to the selected project page let's keep this comment here for now and we'll come back and take care of this a little bit later alright so next let's build the search area of a command palette we have an icon and then an input field and let's start by building the input i'll get rid of a placeholder paragraph here and in place i will use a combobox dot input which will be a search field alright so here it is it is semi-styled already since we're using the forms plugin but i will add some additional styles to make it look like the design that we want let's add a class name attribute and first let's make it use the full width with w full nice let's also add a placeholder text of search right now our input is masking the nice rounded corners and borders of our window so let's make a few changes we'll have a background of transparent bg transparent we will remove the border with border zero and you can see we still have this focus ring here which i will remove with focus ring zero next let's make the input text a little bit smaller with text sm and we'll change the color of the placeholder text and the inputs text so the text will be gray 800 and the placeholder text will be gray 400 all right nice finally i want to just add a little bit of height to this search component so i will add h12 all right it's looking pretty good still needs a little bit of padding here but we'll take care of this next looking at the design we want to add this search icon here on the left like i mentioned at the start this project is using heroicons and i can import the search icon from at heroicons react and we want the one from the outline collection so let's go down here and try to just have the search icon before our input field and it's going to look terrible but let's try that and yep i think it could be a little bit more subtle the first easy win is to control the size of this component with a height of six and a width of six utilities much better and we'll change the color of this icon with text gray 500 i want the icon and the search input side by side so i will have a flex container here which will wrap both these elements and to vertically align these i will use the item center utility nice we're getting close and like i mentioned before we need some horizontal padding and i can use px-4 here all right this is looking great just like the combo box itself the combo box that inputs receives an unchanged prop and this one is going to be responsible to handle our search logic before we can wire up the search we need to actually display some results right now we're showing nothing in our ui so we're going to have a wrapper that has a list of options and then each option will have its individual option component under the combobox.input let me scroll down a bit i will here have a combobox.options component which by default renders as a ul element and will be the container for our individual combobox.option elements which by default are rendered as an li so let's have some placeholder content here we'll have project 1 and i'll duplicate that a couple of times two and three let's look at it and notice that my three options are not showing until i start typing so if i press a key now the three projects are showing so that's the default behavior in the combo box component where the options are only showing once you start interacting with the combo box in our case this is the dialogue opening and closing that makes this decision so we're going to use the static prop on the combobox options to opt out of the default behavior i will simply add static here so now if i refresh the page the three projects are already showing from the start which is the behavior that we want all right so instead of displaying this dummy hard coded project here let's actually display the data that is passed to our components [Music] remember how we passed a project's prop to our comment palette in the layout component let's try display those projects now i'll come at the top of my component here and receive this project's prop and for now let's just console.logit i'll open up my console and nice you can see that we have an array of 12 projects here with all our data so let's remove the console log before we forget and down inside the combo box options instead of these hard coded options i'll keep just one we will iterate over this project array projects.map and for each project i will return a combobox option now this option needs a key with an individual identifier we can use dot id for that and instead of a hard coded project one i will have a div here which will be handy in a second and then let's just output for now the project that title and nice look at this we already have all our projects titled displaying really nicely of course they need a little bit of styling love we want them to look like this so let's apply some utilities to these i will actually first add some utilities to the options wrapper container so up here i will add a class name attribute and we'll first add some vertical padding with py-4 okay and we'll make the text of all the options a little bit smaller with text sm while this is not a problem here with this number results this would certainly become problematic with way more results we want to have a max height to our window here so that we are not having a really really really long window that looks awkward so i will use max height and then i will use something a little bit too small just for demo purposes so i'll go with 40 and obviously now we have a problem with the content overflowing so i will add the class of overflow y dash auto and you can see that just like this we can now scroll through our elements inside the window and we are capped to a maximum height i want to make the window a little bit higher than this i want to use 96 here and i think that's all the elements will actually fit in that window yep all right great so now let's apply some styles to each individual option so i'll add a class name to this div here and we will go with px-4 for horizontal padding and py-2 for vertical padding between the elements now let's make something interesting we're also going to display the team in which each project belongs like you can see here graphql api in engineering and that's kind of why i had a div element here because i want to have a span element here in which i'll display the project title and i will duplicate this and we'll have a span that says in project that team nice so let's style it to create some separation on the parent class i will have a spacing utility with space x1 on the first span which is the project title i will have a font medium a text color of text gray 900 and for the other span we will have a text color of text gray 400 all right that's looking really good now another thing i want to add is this separator between the search and the results you can see in figma we have this thin line here so we can go in the common parent of both these elements which is our combo box and here i will add a divide y utility which will create a vertical separator between each element and since the default color is a bit too dark i will use divide gray 100 for the divide color all right this is very nice let's pause for a second to appreciate what we cannot appreciate because we can't see it right now if i hover over the elements or navigate with the arrow keys you don't get any feedback and may think nothing's happening but the combo box is doing a ton of work for us already behind the scenes we just don't have any styles in place to reflect that right inside the combo box option component let's have a render function here and we're going to receive a render prop called active which is made available by headless ui's combobox option component and i will move all this jsx here inside of the function so headless ui doesn't have any styling opinions but it's going to give you the tools to make your own decisions so here i'm going to change this to use template tags and inside my class name string i will now do a check is the current combo box active question mark and if it is i will add a background class of bg indigo 600 and if it's not i will add a class of bg white and now look at what happens as soon as i start hovering over the results or using my arrow keys down down down down up up up as you can see under the hood headless ui is managing the logic for which element is active has wired up the keyboard navigation which is really really nice all right things don't look that great with the dark text over indigo background so let's fix that once again here i will use backticks and check if the current option is active if it is we are going to have a text color of white and if it's not we're going to grab this text gray 900 and place it in this section of the ternary all right much better let's do the same with the project team if the current option is active if it is not we're going to have a text gray 400 color that we had but if it's active we're going to go with text indigo 200 and look at this now this looks really really nice you might notice a problem down here where we've lost our rounded corners since we've added a background color of white or indigo to our individual options and that's an easy fix in the combo box component which wraps everything i can add an overflow hidden class and we're back to our nice rounded corners all right this is starting to look really good the next thing i want to look at is the actual search behavior right now when i type a search absolutely nothing happens it's just an input field and we haven't wired any logic to that by default the combo box doesn't handle any search functionality and this is by design this allows you full flexibility in your implementation you can do a simple string matching filtering you can implement a fuzzy search library you can do a fetch call to an api or whatever works for your specific scenario for this video we're going to implement a string matching that's going to look at the project titles i'll need another piece of state here so i'll create another use state hook const query and set query equals use state defaulting to an empty string so we're going to wire this query piece of state to our search input let's scroll down to the input component and here where we left ourselves a comment we're going to use the set query state setter that we've just created and i need to receive an event here and i will set the query to event dot target that value which is essentially the value of the input field and just to demo that i will have quickly a pre tag here where we will display the query state just to see that it works so if i start typing in my search input test one two three you can see the piece of state here update immediately all right let's get rid of that pre-tag and now that we know that this piece of state is working we can use this query state value to filter our project by the title let's create a bit of space here and i will create a const filtered project first of all we're going to check if we have a query because if we haven't started typing we don't want to show any result and in case the query is empty we're going to return an empty array so we essentially don't show any results but we still have an array so we don't have an error when we try to map over it so in case we have a query we want to filter out our project so projects that filter and for each project we want to only keep the projects where the project.title includes the query so that should work but there will be a little issue with this i'll go down in the combobox options and here instead of mapping over all the projects i will map over the filtered projects which is the new array that we've created so let's try that api and wow it works but check this out if i have a lowercase i instead of the uppercase i it will not work api returns nothing in other words it's case sensitive and this is likely not what you want from a search where the user is likely to type everything in lowercase so we're going to standardize our search here by adding two lowercase on both sides so project that title that to lowercase includes query that to lowercase so now the comparison is lowercase on both sides and so api will work but api lowercase will also work ios onboarding it works really nicely and again we've done a simple string matching here but you can implement anything that you need since you have full control over the implementation all right right now when we have no search results you can see that we still have that space with the padding for the combobox options and it would probably be much better to remove that space when we haven't started typing a search similarly if i type something that doesn't yield any result i should have a message here that says no results found let's implement that now [Music] first i'm going to wrap the entire combobox.option into a conditional check so we will take filtered project and we will check that the length of this array is greater than zero and if that's the case and i will move the entire combo box options inside of that check we will render it otherwise we will not render it at all and so now you can see that when there's no results there is nothing shown under this is perfect and if i start typing cfo you can see that the results are displayed now if i add a letter which will make the search result empty our search results disappear again in that case i think we should actually display a message that indicates that no results were found i'll go right down after the combobox options and here i'll have another check we'll first check that we have a query so that the query string is not an empty string and we'll check that the length of the filtered project array is this time equal to zero in other words we have no search results and if these two conditions are true i will render a b tag that says no results found api nice this is working obviously it needs a little bit of styling let's add a class name with p 4 and we'll make the text a bit smaller text sm and text gray 500 all right this is starting to look really really solid we still need to handle the page navigation when the user selects one of the projects by clicking on it or pressing the enter key remember we left ourselves a comment here so let's implement that now this unchanged event which happens when a user clicks on one of the elements or presses enter will actually give us a value and this value is handed to us from the combo box option component so if i come down here right now our combo box option does not have any value i need to assign a value to this component here and we're going to set that to the whole project the individual project for this combo box option and so now with that in place when we select this option the project will be available here as a value and so i can change this to project actually and so what we want to do now is navigate the user to the individual project page if we look at the urls for our project for a minute if i click on any of this existing project like new customer portal it will redirect me to slash projects eight i can try another one graphql api and it slash project slash one so this number is equal to the project id in our data and since i received this project data i can redirect the user to a route of slash projects slash project.id one way to do this could be to change the window.location.href and set it to slash project slash project that id that once again we're getting from this value here so let's try that let's search for ios and i will press enter here not sure if you've noticed but there was a full page refresh here and we saw a little flash and if you wonder why the comment palette is still open well remember we have this ease open state that we set to true by default and since we've loaded a new page well our dialog component is open to start with i'll do a second demo this time focus on the background and you should be able to see the ui flash when the page reloads three two one so that's not a big deal but let's see if we can make the experience a little bit better in this case we're using next.js so we can implement a client-side navigation by using the next router so up here i will import the use router hook from next router and down in the start of the component i will access the router with const router equals use router and now i can go down in the unchanged here for the combo box and instead of doing this window location href change i can use router.push sending the user to slash projects slash project.id i will search for benefits when i press enter this time we will have a seamless client side navigation of course you can see that the command palette remains open and we have maintained our query state let's fix that next so i can remove that command now since this is done and we can easily fix the command palette remaining open by simply here using set is open to false remember this is all it takes to control the open close state of the dialog because this is what we've used here as a piece of state so now let's try one more time emails and this time it should close the comment palette and navigate immediately nice all right so now our comment palette closes but speaking of closing here's one thing that is crucially missing from our project how do we actually open the command palette up to this point i've set the default value of ease open to be true so refreshing the page is our only way to have the comment palette showing let's change that [Music] all right remember comment palettes are for power users we don't want to have to reach the mouse and then find a button to click so we're going to provide our users with a custom keyboard shortcut to trigger the comment palette we've seen a few examples of comment palettes that we're opening with the command plus p key combo but there's a problem with that on the web since this is the default printing behavior when you implement a custom keyboard shortcut like we're about to be a good citizen and consider whether you're overriding a default browser behavior which is not always helpful basically i want to add an event listener for the key down event and the right place to add this behavior in a react project would be inside a use effect so where i import use state i will also import use effect from react and after a query state here let's create a use effect so it will have a function and then let's not forget the array of dependencies after that all right so i want to add an event listener here and i will go with window dot adds event listener and we want to listen to the key down event and then when that happens we want to run a custom function that we will write in a second and let's call it on key down and the reason we're going to put our logic in the custom function instead of inline here is we want to also be able to remove the event listener when the component unmounts to clean up after ourselves and in the use effect you can return a function which will run when the component unmounts and here we will go with window that remove event listener and listen to the same event and remove the same on key down function and of course here we need to define our custom function so a common shortcut for comment palettes on the web is command plus k or control plus k on windows so in that function we want to receive an event which will be the key down event and then if the event dot key which is the key pressed is equal to okay and we also have the event dot meta key which is the command key or the event dot control key ctrl key to support windows and i just need to add parentheses here to make sure this is evaluated together so when that happens we just want to toggle the command palette and it's really easy to do that all i need to do is change the value of ease open with set is open which we will set to the opposite of is open now because we're using this ease open value inside our use effect you can see that we have a little warning and i need to add this ease open to my array of dependencies and let's go try that out i'm going to log my keyboard presses on the bottom left so you can see the characters that i'm pressing and now i'm going to hit command k and the command palettes open nice let's hit command k again and it's closed and let's try ctrl k as well and yep it works this means that i can finally change the ease open default value which is set to true here to false like i mentioned up to this point refreshing the page to set is open to true was our only way to show the comment palette but now we have a really convenient keyboard shortcut to toggle it on and off we're almost done just one more thing that i think will take our project to the next level if i comment k very quickly you can see that the way our comment palette appears and disappears is very abrupt and almost a little bit jarring and confusing at times i think we can make things much smoother by adding a subtle transition when the common palette opens and closes nothing crazy over the top probably just a fade in with a subtle scale up when it opens and scale down when it closes [Music] all right so to handle the enter and leave transitions i will use another headless ui component the transition component first thing first let's import it next to dialog and combo box and we're going to use nested transitions to apply different transition animations to different elements in this component i'll start by wrapping the entire dialog component in a transition that root component which will take care of orchestrating all the transitions and this component needs to receive a show prop to determine when an entering transition and a leaving transition should occur we'll use our ease open state value and when we do that we can actually remove the open prop on the dialog component since it now will read the show state from the transition component automatically the transition component will by default render as a div element so here we don't really need to add a wrapping element so i will add an as prop and set this transition route to be a react fragment instead i just need to go to the top of my file and import fragment from react as well alright so now that we have our transition route i want to animate the overlay right here in a specific transition so i will wrap that element in a transition child component we don't need the show prop here the transition route element is responsible for that but here i will add six different props that will allow me to control the animation on enter and on leave all of these six props can receive css classes and we'll use tailwind utilities here for the enter prop which is the classes that need to be present during the entire enter transition i want to set a duration of 300 milliseconds and i'll add an easing curve of ease out enter from which is our starting state for the transition will be simply opacity 0 and enter 2 will be opacity 100 and i bet you can imagine what the transition will look like our overlay's opacity will animate from 0 to 100 over 300 milliseconds let's give it a try with command k and pay attention to the overlay as it appears nice once again so let's take care of the leave transition we're going to do something very similar here the duration will be slightly shorter with 200 milliseconds the easing this time will be ease in and we'll reverse the animation we'll start with full opacity and end the transition with no opacity at all and let's take a look just with that overlay transition by itself the whole command palette toggling experience already feels much nicer the search bar itself still feels a little bit abrupt especially on leave but we're going to take care of that now since it's very similar i will copy this transition child here and i will paste it here to wrap our combo box component and from here we're going to tweak these transition props since we're doing a similar yet slightly different transition the enter and leave props will actually remain exactly the same but in the end of from we'll start with zero percent opacity and a scale of 95 and we'll finish the entire transition with 100 opacity and a scale of 100 so we're basically fading in our combo box but also slightly scaling it up and for the leave we'll do the exact opposite opacity 100 and scale 100 and opacity 0 scale 95 at the end of the leaf transition alright and let's take a look at that let's toggle the command palette and close it that is looking sweet you can see the opacity fade and scale up and down as i toggle the comment palette all right we almost completely done there is just this subtle little bug i want to show you and fix right now remember that when we toggle the comment palette no result is shown until i start typing so let me search for cfo and we will go visit that project and everything works properly but now let me reopen the comment palette with command k and you can see our higher cfo project in the results already even if the search input is empty since the command palette component technically never was unmounted a search query which we used to filter the search results is still set to what i've searched before which was cfo we actually want to reset the query state to an empty string whenever an element of the combo box is selected just like we've done for the set is open false in our combo box parents on change prop you might be tempted to reset the query state here with set query of an empty string but there's a problem with this by resetting the query state here we will actually notice the search results change i will search for graphql and as i press enter pay close attention to the search results ready so next time i open the comment palette we don't have that bug anymore since the query state has been reset but we have that kind of jarring experience especially if there's many results where all the results disappear while the command palette is fading out what would be amazing here is to be able to reset the query after the element has finished transitioning after the leave transition well this is a lucky day on the transition root component here i can add a after leave transition just like we hoped for which just like you can imagine will allow us to execute some code after the leave transition is complete and so here's the place where i'm going to set query to an empty string and let's make sure we remove it from our combo box here and with that we should have a super smooth user experience so i'll do a search navigate to one of the search results and i hope here is when i press enter the comment palette fades out completely before the query state is reset and therefore the results are removed ready nice and if i reopen the command palette it's displaying its proper starting state and with that we are done all right let's do a quick recap of everything we've done because there was a lot [Music] first we used a dialogue component from headless ui to handle all the complex implementation of modal windows all we needed to do is keep track of a little ease open piece of state and let headless ui handle the rest for us then we used a combo box component from headless ui we wired the combobox input component to another piece of state a search query string which we used to filter our project based on the search input this allowed us to display the relevant search results in our combo box options headless ui did a ton of hard work for us behind the scenes and also gave us this active render prop which allowed us to style the currently active project in the combo box differently we then used a bit of conditional logic to handle empty states we provided a custom keyboard shortcut to toggle the command palette like a power user we used the next router to do a client-side navigation when the user selects a project and finally we added a layer of polish and delight by adding some orchestrated transitions with headless ui's transition component [Music] i hope you enjoyed this video and found it useful if you happen to have a tailwind ui license and are interested in more common palette ideas we recently added an entirely new comment palette category to tailwind ui and that about wraps it up for this video thank you so much for sticking with me until the end and as always i will see you in the next video bye for now [Music]
Info
Channel: Tailwind Labs
Views: 66,099
Rating: undefined out of 5
Keywords:
Id: -jix4KyxLuQ
Channel Id: undefined
Length: 37min 59sec (2279 seconds)
Published: Tue Feb 22 2022
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.