Hey les développeurs et développeuses, 👋
Aujourd'hui nous allons développer un composant React pour remplacer un composant select ou radio.
Dernièrement je vous parlais d'un composant ReactJS que j'ai designé de manière à rendre plus pratique un choix multiple de 3, 4 ou 5 items:

Surtout dans une situation où on l'on peut mettre une icone ou une image pour un choix, cela rend les choses beaucoup plus simple à visualiser.
Pour réaliser ce composant, j'ai utilisé React, React-hook-form, Tailwind sur NextJS 14.
### Composants
Je commence par créer un composant CircleButton.tsx:
export type CircleButtonProps = {
children: React.ReactNode;
onClick: () => void;
}
export const CircleButton = ({ children, onClick }: CircleButtonProps) => {
return (
<div
className="w-28 h-28 bg-white border border-gray-200 rounded-full shadow dark:bg-gray-800 dark:border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-300"
onClick={onClick}
>
{children}
</div>
);
};
Ensuite, je crée un composant CircleChoice.tsx:
import { useState } from "react";
import { Control, Controller } from "react-hook-form";
import { CircleButton } from "./circleButton";
import cn from "classnames";
import styles from "./CircleChoice.module.css"
type Choice = {
value: string;
content: React.ReactNode;
};
export type CircleChoiceProps<T> = {
fieldTitle: string;
fieldName: string;
control: Control<any>;
choices: Choice[];
};
export const CircleChoice = <T,>({ fieldName, control, fieldTitle, choices }: CircleChoiceProps<T>) => {
const [isOpened, setIsOpened] = useState(false);
const radius = 120;
return (
<div className="mb-6 relative flex flex-col items-center justify-center">
<Controller
control={control}
name={fieldName}
render={({ field }) => (
<>
<div
className="fixed top-0 left-0 w-full h-full bg-black opacity-50 z-10"
style={{ display: isOpened ? "block" : "none" }}
onClick={() => setIsOpened(false)}
>
</div>
<div className="relative z-20">
<CircleButton
onClick={() => {
setIsOpened(!isOpened);
field.onChange(field.value);
}}
>
{
field.value ?
choices.find(choice => choice.value === field.value)?.content :
fieldTitle
}
</CircleButton>
{isOpened && (
<div
className={`absolute inset-0 transition-all duration-500 scale-100`}
onClick={() => setIsOpened(false)}
>
<div
onClick={() => setIsOpened(false)}
className="absolute w-12 h-12 bg-gray-300 border border-gray-400 rounded-full shadow dark:bg-gray-800 dark:border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-400"
style={{
transform: `translate(${radius / 4}px, ${radius / 4}px)`,
}}>
✖
</div>
{choices.map((choice, index) => {
return (
<div
key={choice.value}
className={cn("absolute transition-transform duration-500",
styles[`circle-button-anim-${choices.length}-${index}`]
)}
>
<CircleButton
onClick={() => {
field.onChange(choice.value);
setIsOpened(false);
}}
>
{choice.content}
</CircleButton>
</div>
);
})}
</div>
)}
</div>
</>
)}
/>
</div>
);
};
Et enfin j'ajoute mon module pour le CSS dans un fichier CircleChoice.module.css:
@keyframes moveAway-3-0 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(0px, -120px);
opacity: 1;
}
}
@keyframes moveAway-3-1 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(103.92px, 60px);
opacity: 1;
}
}
@keyframes moveAway-3-2 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(-103.92px, 60px);
opacity: 1;
}
}
.circle-button-anim-3-0 {
animation: moveAway-3-0 0.5s ease-out forwards;
}
.circle-button-anim-3-1 {
animation: moveAway-3-1 0.5s ease-out forwards;
}
.circle-button-anim-3-2 {
animation: moveAway-3-2 0.5s ease-out forwards;
}
@keyframes moveAway-4-0 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(120px, 0px);
opacity: 1;
}
}
@keyframes moveAway-4-1 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(0px, 120px);
opacity: 1;
}
}
@keyframes moveAway-4-2 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(-120px, 0px);
opacity: 1;
}
}
@keyframes moveAway-4-3 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(0px, -120px);
opacity: 1;
}
}
.circle-button-anim-4-0 {
animation: moveAway-4-0 0.5s ease-out forwards;
}
.circle-button-anim-4-1 {
animation: moveAway-4-1 0.5s ease-out forwards;
}
.circle-button-anim-4-2 {
animation: moveAway-4-2 0.5s ease-out forwards;
}
.circle-button-anim-4-3 {
animation: moveAway-4-3 0.5s ease-out forwards;
}
@keyframes moveAway-5-0 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(120px, 0px);
opacity: 1;
}
}
@keyframes moveAway-5-1 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(37.08px, 114.43px);
opacity: 1;
}
}
@keyframes moveAway-5-2 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(-97.08px, 70.63px);
opacity: 1;
}
}
@keyframes moveAway-5-3 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(-97.08px, -70.63px);
opacity: 1;
}
}
@keyframes moveAway-5-4 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(37.08px, -114.43px);
opacity: 1;
}
}
.circle-button-anim-5-0 {
animation: moveAway-5-0 0.5s ease-out forwards;
}
.circle-button-anim-5-1 {
animation: moveAway-5-1 0.5s ease-out forwards;
}
.circle-button-anim-5-2 {
animation: moveAway-5-2 0.5s ease-out forwards;
}
.circle-button-anim-5-3 {
animation: moveAway-5-3 0.5s ease-out forwards;
}
.circle-button-anim-5-4 {
animation: moveAway-5-4 0.5s ease-out forwards;
}
Enfin, à l'utilisation, j'ajoute dans mon composant le bout de code suivant:
const VEHICLES = {
"car": "Voiture",
"plane": "Avion",
"train": "Train",
"bus": "Bus"
}
// ...
<CircleChoice
fieldName="vehicle"
control={control}
fieldTitle="Choix du véhicule"
choices={Object.entries(VEHICLES).map(([key, value]) => ({
value,
content: (<>
<img
src={`/vehicles/${value.toLowerCase()}.png`}
alt={key} className="w-10 h-10 mb-2" />
<span className="text-sm">{key}</span>
</>)
}))}
/>
Voilà, c'est assez simple et efficace je trouve, bon code à vous ! 😉
Je vous laisse personnaliser cela selon vos besoins.