๐ Recap from Previous Post
In the previous post, we focused on building the
ChatbotCard
componentโan essential part of our Gallery UI. You learned how to:โ Create reusable UI components like
Card
,CardContent
,CardFooter
, andButton
.
โ Set up a flexible utility functioncn()
to manage dynamic Tailwind class names.
โ Define theChatbot
andPersona
types for clean data structure.
โ Render a responsive grid of chatbot cards on the Gallery page using sample data.
โ Ensure theGallery
is the default landing page by updatingapp/page.tsx
.By the end of that post, your app shouldโve been able to display a functional and neatly designed grid of chatbot cards that preview character info.
Creating Searchbar & Filter
๐ฏ Goals for This Post
In this part of the tutorial, weโll focus on building the Searchbar and Filter functionality for the Gallery page. To keep things simple and digestible, weโll save the โAdd Chatbotโ button for the next post.
By the end of this guide, youโll have a fully functional UI where users can:
-
Search by Name โ Filter visible
ChatbotCards
by typing a name in the search bar. - Filter by Interests โ Click on an interest tag to show only characters who have that interest.
- Filter by Personality โ Click on a personality tag to display only characters matching that trait.
Your final result will look like the image belowโa responsive page with cards, a searchbar, and clickable filters ready to enhance user experience.
๐ฑ Step by Step Guide
1. Creating the Searchbar
First thing first, weโll create a new file called Searchbar.tsx
and weโll put it in src/modules/gallery/components/Searchbar.tsx
, the reason is the Searchbar
will be used in this page only hence it will be better to put it in components under gallery instead of shared components folder.
Hereโs the code for it:
import React from "react";
import Input from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export const SearchBar = ({
value,
onChange,
placeholder = "Search chatbots...",
}: SearchBarProps) => {
return (
<div className="relative flex-grow">
<Input
type="text"
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(e.target.value)
}
placeholder={placeholder}
className={cn(
"w-full",
"border-2 border-purple-200 bg-white/80 backdrop-blur-sm",
"text-gray-700 placeholder:text-purple-400/70",
"rounded-xl shadow-sm",
"focus:border-purple-500 focus:ring-4 focus:ring-purple-500/20",
"focus:bg-white focus:shadow-lg focus:shadow-purple-500/10",
"transition-all duration-300 ease-out",
"hover:border-purple-300 hover:shadow-md"
)}
aria-label="Search chatbots"
/>
</div>
);
};
The Searchbar
UI follows a common design found in many modern web apps. It accepts standard props such as value
, onChange
, and placeholder
. This allows us to control its state and update the input dynamically as the user types.
If you take a closer look, you might notice weโre using a component that hasnโt been created yet: the Input
component from our shared UI folder and thatโs what weโll work on next.
Creating Input
component
The Input
component is essentially a styled wrapper around the standard <input>
element. Its purpose is to maintain a consistent design and behavior across all input fields in our project.
Since this is a shared UI component, weโll place it in src/components/ui/input.tsx
.
Hereโs the code:
import React, { FC, InputHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type InputProps = InputHTMLAttributes<HTMLInputElement>;
const Input: FC<InputProps> = ({ className, ...props }) => {
return (
<input
className={cn(
"flex h-10 w-full rounded-md border border-input bg-white dark:bg-gray-800 px-3 py-2 text-sm text-black dark:text-black ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 dark:placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
);
};
export default Input;
Integrating the Searchbar
into the Gallery Page
Now that our Searchbar
component is complete, we can add it to the GalleryPage
and connect it to the componentโs state. This will allow us to dynamically filter the displayed ChatbotCards
based on the character names as the user types.
First, letโs create a new <div>
positioned alongside the existing ChatbotCard
components. Inside this new container, weโll place the Searchbar
component.
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Chatbot Gallery</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="md:col-span-1">
<div
className="sticky top-4 space-y-6"
role="search"
aria-label="Search and filter chatbots"
>
<div className="flex gap-x-2">
<SearchBar
value={""}
onChange={() => {}}
placeholder="Search chatbots by name or description..."
/>
</div>
</div>
</div>
<div className="md:col-span-3">
<div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
role="region"
aria-label="Chatbot list"
aria-live="polite"
>
{/* ------------ Rest of the HTMLS ------------- */}
</div>
</div>
</div>
</div>
Next, weโll add a new state inside the GalleryPage
component called searchTerm
. This state will store the value typed into the search bar.
Weโll then pass searchTerm
as the value
prop to the Searchbar
, and set the onChange
prop to update it using setSearchTerm
.
Hereโs the full example of how that looks in context:
const GalleryPage = () => {
const [searchTerm, setSearchTerm] = useState("");
const onCardClick = () => {
// Do nothing yet
};
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Chatbot Gallery</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="md:col-span-1">
<div
className="sticky top-4 space-y-6"
role="search"
aria-label="Search and filter chatbots"
>
<div className="flex gap-x-2">
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search chatbots by name or description..."
/>
</div>
</div>
</div>
<div className="md:col-span-3">
<div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
role="region"
aria-label="Chatbot list"
aria-live="polite"
>
{/* ------------ Unrelated Parts ------------- */}
</div>
</div>
</div>
</div>
);
};
Next, weโll filter the chatbot data based on the searchTerm
, and pass the filtered result to the ChatbotCards
group. This allows the list to dynamically update and only show chatbot cards that match the userโs input in the search bar.
First, weโll rename the existing sample data from filteredChatbots
to chatbotsData
to avoid confusion. Then, inside the GalleryPage
component, weโll create a new variable called filteredChatbots
.
This new variable will filter chatbotsData
using the searchTerm
, returning a refined list of chatbot cards that match the search query.
Hereโs an example of the code:
// ... imports and sample datas
const GalleryPage = () => {
const [searchTerm, setSearchTerm] = useState("");
const onCardClick = () => {
// Do nothing yet
};
const filteredChatbots = useMemo(() => {
return chatbotsData.filter((persona) => {
const matchesSearch = persona.name
.toLowerCase()
.includes(searchTerm.toLowerCase());
return matchesSearch;
});
}, [chatbotsData, searchTerm]);
// ... rest of the codes
By now, your search functionality should be working. Typing into the search bar will dynamically filter the displayed ChatbotCards
based on their names.
It should look something like this:
2. Creating the Filter
Now, let's move on to building the filter functionality.
On desktop, filters will be displayed as clickable tag-like buttons (as shown in the image above). On mobile, the same filters will be accessible through dropdown menus for a more compact and user-friendly interface (see image below). Once you select an interest or personality from the dropdown, a tag will appear showing your selected filterโmaking it easy to see and manage which filters are currently active.
First, weโll begin by building the desktop view of the filter component.
Create a new file named FilterControl.tsx
, located at: src/modules/gallery/components/FilterControl.tsx
.
Since each tag-like filter should be interactive (clickable), weโll use the reusable Button
component to render them. This ensures consistent styling and behavior across the app.
Hereโs the code for FilterControl.tsx
:
import React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FilterControlsProps {
value: string[];
selectedValue: string[];
onToggle: (value: string) => void;
}
export const FilterControls = ({
value,
selectedValue,
onToggle,
}: FilterControlsProps) => {
// Shared tag styling for both mobile and desktop
const getTagStyles = (isSelected: boolean) => {
return cn(
"rounded-full px-4 py-2 text-sm font-semibold",
"transition-all duration-200",
isSelected
? "bg-gradient-to-r from-purple-600 to-pink-500 text-white shadow-md shadow-purple-500/25 hover:shadow-lg hover:shadow-purple-500/40"
: "border-2 border-purple-200 text-purple-600 hover:bg-purple-100 hover:border-purple-300 hover:shadow-md"
);
};
return (
<>
{/* Desktop view: Button group */}
<div className="hidden md:flex flex-wrap gap-2">
{value.map((e) => (
<Button
key={e}
onClick={() => onToggle(e)}
variant={selectedValue.includes(e) ? "default" : "outline"}
size="sm"
className={getTagStyles(selectedValue.includes(e))}
aria-pressed={selectedValue.includes(e)}
role="checkbox"
aria-checked={selectedValue.includes(e)}
>
{e}
</Button>
))}
</div>
</>
);
};
In the code above, we created a helper function called getTagStyles
to keep things tidy and avoid repeating the same classNames
. This function will also be reused by other components in this file later on.
The FilterControls
component accepts several props:
-
values
: the values to be displayed. -
selectedValue
: the currently selected filter. -
onToggle
: a callback function triggered when the button is clicked
These props allow the filter controls to behave dynamically and respond to user interaction effectively.
Next, weโll move on to building the mobile view, starting with the dropdown button. For this, weโll use the native <select>
and <option>
elements, styled to match the rest of our UI.
Hereโs the updated code for FilterControls
:
import React, { useRef } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FilterControlsProps {
values: string[];
selectedValue: string[];
onToggle: (value: string) => void;
}
export const FilterControls = ({
values,
selectedValue,
onToggle,
}: FilterControlsProps) => {
const selectRef = useRef<HTMLSelectElement>(null);
// For mobile view, we'll use a Select component
const handleSelectChange = (value: string) => {
if (value) {
onToggle(value);
// Reset the select to default option after selection
if (selectRef.current) {
selectRef.current.value = "";
}
}
};
// Shared tag styling for both mobile and desktop
const getTagStyles = (isSelected: boolean) => {
return cn(
"rounded-full px-4 py-2 text-sm font-semibold",
"transition-all duration-200",
isSelected
? "bg-gradient-to-r from-purple-600 to-pink-500 text-white shadow-md shadow-purple-500/25 hover:shadow-lg hover:shadow-purple-500/40"
: "border-2 border-purple-200 text-purple-600 hover:bg-purple-100 hover:border-purple-300 hover:shadow-md"
);
};
return (
<>
{/* Mobile view: Select dropdown and tags */}
<div className="md:hidden space-y-3">
<select
ref={selectRef}
onChange={(e) => handleSelectChange(e.target.value)}
className={cn(
"w-full h-12 px-4 py-3 text-sm bg-white/80 backdrop-blur-sm",
"border-2 border-purple-200 rounded-xl",
"text-gray-700 placeholder:text-purple-400/70",
"focus:border-purple-500 focus:ring-4 focus:ring-purple-500/20",
"focus:bg-white focus:shadow-lg focus:shadow-purple-500/10",
"transition-all duration-300 ease-out",
"hover:border-purple-300 hover:shadow-md"
)}
aria-label="Filter by personality"
>
<option value="">Select an interest</option>
{values.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
</select>
</div>
{/* Desktop view: Button group */}
<div className="hidden md:flex flex-wrap gap-2">
{values.map((e) => (
<Button
key={e}
onClick={() => onToggle(e)}
variant={selectedValue.includes(e) ? "default" : "outline"}
size="sm"
className={getTagStyles(selectedValue.includes(e))}
aria-pressed={selectedValue.includes(e)}
role="checkbox"
aria-checked={selectedValue.includes(e)}
>
{e}
</Button>
))}
</div>
</>
);
};
As shown in the code above, we added a new container next to the desktop viewโs container to hold the dropdown menu. This mobile-specific container is only visible on screens smaller than the medium breakpoint (i.e., mobile devices).
We also introduced a new function to handle dropdown selections called handleSelectChange
. When a user selects an option, this function calls the onToggle
handler to update the selected filter, and then resets the <select>
value to allow for repeated selections. The selected values will then be displayed as buttons below the dropdown which weโll implement next.
next weโll work on the tags like component for the mobile view. itโs pretty simple and similar to the desktop view part.
Hereโs the updated code:
import React, { useRef } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FilterControlsProps {
values: string[];
selectedValues: string[];
onToggle: (value: string) => void;
}
export const FilterControls = ({
values,
selectedValues,
onToggle,
}: FilterControlsProps) => {
const selectRef = useRef<HTMLSelectElement>(null);
// For mobile view, we'll use a Select component
const handleSelectChange = (value: string) => {
if (value) {
onToggle(value);
// Reset the select to default option after selection
if (selectRef.current) {
selectRef.current.value = "";
}
}
};
// Shared tag styling for both mobile and desktop
const getTagStyles = (isSelected: boolean) => {
return cn(
"rounded-full px-4 py-2 text-sm font-semibold",
"transition-all duration-200",
isSelected
? "bg-gradient-to-r from-purple-600 to-pink-500 text-white shadow-md shadow-purple-500/25 hover:shadow-lg hover:shadow-purple-500/40"
: "border-2 border-purple-200 text-purple-600 hover:bg-purple-100 hover:border-purple-300 hover:shadow-md"
);
};
return (
<>
{/* Mobile view: Select dropdown and tags */}
<div className="md:hidden space-y-3">
<select
ref={selectRef}
onChange={(e) => handleSelectChange(e.target.value)}
className={cn(
"w-full h-12 px-4 py-3 text-sm bg-white/80 backdrop-blur-sm",
"border-2 border-purple-200 rounded-xl",
"text-gray-700 placeholder:text-purple-400/70",
"focus:border-purple-500 focus:ring-4 focus:ring-purple-500/20",
"focus:bg-white focus:shadow-lg focus:shadow-purple-500/10",
"transition-all duration-300 ease-out",
"hover:border-purple-300 hover:shadow-md"
)}
aria-label="Filter by personality"
>
<option value="">Select an interest</option>
{values.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
</select>
{/* Mobile selected tags display */}
{selectedValues.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{selectedValues.map((e) => (
<Button
key={e}
onClick={() => onToggle(e)}
variant="default"
size="sm"
className={getTagStyles(true)}
aria-pressed={true}
role="checkbox"
aria-checked={true}
>
{e}
</Button>
))}
</div>
)}
</div>
{/* Desktop view: Button group */}
<div className="hidden md:flex flex-wrap gap-2">
{values.map((e) => (
<Button
key={e}
onClick={() => onToggle(e)}
variant={selectedValues.includes(e) ? "default" : "outline"}
size="sm"
className={getTagStyles(selectedValues.includes(e))}
aria-pressed={selectedValues.includes(e)}
role="checkbox"
aria-checked={selectedValues.includes(e)}
>
{e}
</Button>
))}
</div>
</>
);
};
The new section marked with the comment Mobile selected tags display
checks whether there are any selectedValues
. If so, it displays them accordingly.
Now, the final step is to integrate these components into the GalleryPage
and manage the state for the selected values.
Letโs begin with the Interest Filter. First, add the FilterControl
component right below the Searchbar
. Then, create a new state to hold the selected filter value and define a function to handle the onToggle
logic for the FilterControl
.
Hereโs an example implementation:
const GalleryPage = () => {
const [searchTerm, setSearchTerm] = useState("");
const [selectedInterest, setSelectedInterest] = useState<string[]>([]);
const onCardClick = () => {
// Do nothing yet
};
// Get all unique interests
const allInterests = useMemo(() => {
const interests = new Set<string>();
chatbotsData.forEach((persona) => {
// Collect unique interests from personas
if (persona.interests) {
persona.interests.split(",").forEach((el) => interests.add(el.trim()));
}
});
return Array.from(interests).sort();
}, [chatbotsData]);
const filteredChatbots = useMemo(() => {
return chatbotsData.filter((persona) => {
const matchesSearch = persona.name
.toLowerCase()
.includes(searchTerm.toLowerCase());
const matchesInterests =
selectedInterest.length === 0 ||
selectedInterest.some((e) => persona.interests?.includes(e));
return matchesSearch && matchesInterests;
});
}, [chatbotsData, searchTerm, selectedInterest]);
const handleInterestsToggle = (tag: string) => {
setSelectedInterest((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
return (
// .... rest of the code
<div className="flex gap-x-2">
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search chatbots by name or description..."
/>
</div>
{/* Filter for Interests */}
<div className="bg-white p-4 rounded-lg shadow">
<h2 className="text-lg font-medium mb-3">Filter by Interests</h2>
<FilterControls
values={allInterests}
selectedValues={selectedInterest}
onToggle={handleInterestsToggle}
/>
</div>
/// .... rest of the code
);
};
As shown above, weโve made several updates to the GalleryPage
:
- Added a
useState
hook to manage theselectedInterests
. - Defined
allInterests
to extract all unique interests from the chatbot data. - Updated
filteredChatbots
to include filtering logic based on selected interests. - Created
handleInterestsToggle
to handle changes when a filter button is toggled.
At this point, the filter should be functional and working as expected. Feel free to try it out and see it in action!
The final step is to implement the filter for Personality. This will follow the same pattern as the Interests filterโso if youโd like, you can give it a try yourself first before checking the solution below.
Hereโs the code example:
"use client";
import React, { useMemo, useState } from "react";
import { ChatbotCard } from "./components/ChatbotCard";
import { SearchBar } from "./components/Searchbar";
import { FilterControls } from "./components/FilterControl";
const chatbotsData = [
{
id: "1",
name: "Chatbot 1",
avatarUrl: "",
behaviour: "Chatbot 1 behaviour",
personality: "professional, detail-oriented, calm",
interests: "planning, data organization, note taking",
tone_style: "formal, friendly",
created_at: new Date().getTime(),
updated_at: new Date().getTime(),
},
{
id: "2",
name: "Chatbot 2",
avatarUrl: "",
behaviour: "Chatbot 2 behaviour",
personality: "playful, witty, chill",
interests: "astrology, indie music, coffee",
tone_style: "casual, friendly, flirty sometimes",
created_at: new Date().getTime(),
updated_at: new Date().getTime(),
},
{
id: "3",
name: "Chatbot 3",
avatarUrl: "",
behaviour: "Chatbot 3 behaviour",
personality: "playful, teasing, proud",
interests: "Sweets, Desserts, Fashion",
tone_style: "casual with friends, cold with others",
created_at: new Date().getTime(),
updated_at: new Date().getTime(),
},
];
const GalleryPage = () => {
const [searchTerm, setSearchTerm] = useState("");
const [selectedInterest, setSelectedInterest] = useState<string[]>([]);
const [selectedPersonality, setSelectedPersonality] = useState<string[]>([]);
const onCardClick = () => {
// Do nothing yet
};
// Get all unique interests
const allInterests = useMemo(() => {
const interests = new Set<string>();
chatbotsData.forEach((persona) => {
// Collect unique interests from personas
if (persona.interests) {
persona.interests.split(",").forEach((el) => interests.add(el.trim()));
}
});
return Array.from(interests).sort();
}, [chatbotsData]);
// Get all unique personality
const allPersonality = useMemo(() => {
const personalities = new Set<string>();
chatbotsData.forEach((persona) => {
// Collect unique interests from personas
if (persona.personality) {
persona.personality
.split(",")
.forEach((el) => personalities.add(el.trim()));
}
});
return Array.from(personalities).sort();
}, [chatbotsData]);
const filteredChatbots = useMemo(() => {
return chatbotsData.filter((persona) => {
const matchesSearch = persona.name
.toLowerCase()
.includes(searchTerm.toLowerCase());
const matchesInterests =
selectedInterest.length === 0 ||
selectedInterest.some((e) => persona.interests?.includes(e));
const matchesPersonalities =
selectedPersonality.length === 0 ||
selectedPersonality.some((e) => persona.personality?.includes(e));
return matchesSearch && matchesInterests && matchesPersonalities;
});
}, [chatbotsData, searchTerm, selectedInterest, selectedPersonality]);
const handleInterestsToggle = (tag: string) => {
setSelectedInterest((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
const handlePersonalityToggle = (tag: string) => {
setSelectedPersonality((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Chatbot Gallery</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="md:col-span-1">
<div
className="sticky top-4 space-y-6"
role="search"
aria-label="Search and filter chatbots"
>
<div className="flex gap-x-2">
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search chatbots by name or description..."
/>
</div>
{/* Filter for Interests */}
<div className="bg-white p-4 rounded-lg shadow">
<h2 className="text-lg font-medium mb-3">Filter by Interests</h2>
<FilterControls
values={allInterests}
selectedValues={selectedInterest}
onToggle={handleInterestsToggle}
/>
</div>
{/* Filter for Personality */}
<div className="bg-white p-4 rounded-lg shadow">
<h2 className="text-lg font-medium mb-3">
Filter by Personality
</h2>
<FilterControls
values={allPersonality}
selectedValues={selectedPersonality}
onToggle={handlePersonalityToggle}
/>
</div>
</div>
</div>
<div className="md:col-span-3">
<div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
role="region"
aria-label="Chatbot list"
aria-live="polite"
>
{filteredChatbots.map((persona) => {
const interests = persona.interests
? persona.interests.split(",").map((i) => i.trim())
: [];
const personality = persona.personality
? persona.personality.split(",").map((p) => p.trim())
: [];
return (
<div key={persona.id}>
<ChatbotCard
chatbot={{
id: persona.id,
name: persona.name,
avatarUrl: "",
behavior: persona.behaviour || "",
personality: personality,
interests: interests,
tone_style: persona.tone_style || "",
createdAt:
new Date(persona.created_at).toISOString() ||
new Date().toISOString(),
updatedAt:
new Date(persona.updated_at).toISOString() ||
new Date().toISOString(),
}}
onClick={onCardClick}
/>
</div>
);
})}
{filteredChatbots.length === 0 && (
<div className="col-span-full text-center py-12">
<p className="text-sm text-gray-500">
No chatbots match your search criteria
</p>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default GalleryPage;
As you can see above, we added the same pieces of logic we used previously for the Interests filter. These include:
-
handlePersonalityToggle
โ handles toggle changes for the Personality filter. -
allPersonality
โ extracts all unique personality traits from the chatbot data. - A new
useState
hook to store the selectedPersonality value. - An updated
filteredChatbots
logic to include the Personality filter
Thatโs all for today! ๐
You can now try out what youโve built in this post. You should be able to filter chatbot results by name (using the Searchbar
), personality, and/or interests. It should looks like these images below
๐ Whatโs Next?
In the next post, weโll work on adding the Add Chatbot button and form, including how to integrate it with the API we discussed back in Part 2.
New posts will be released every 2โ3 days, so make sure to subscribe or bookmark this page to stay updated!
Please check Part 5 here:
๐ (Part 5) Build a Simple Chat Character Gallery: Adding Create New Chatbot Feature
Top comments (0)