Créer un bouton polymorphe avec Typescript, React, Next.js & TailwindCSS

Publié le 8 mars 2024
Baptiste Drillien avatar
Baptiste Drillien
Développeur front-end

Dans une application web, il n’est pas rare de voir un composant qui a différents comportements sans que son style change.

Par exemple, un bouton peut avoir un onClick, mais il peut également avoir un href venant d'un <a> ou d'un <Link> de Next.js.

Peu importe son comportement, le composant bouton doit conserver le style du design system : c’est là qu’interviennent les composants dits “polymorphes”.

Dans cet article, nous allons voir comment créer un bouton polymorphe, qui garde un style similaire mais peut changer son comportement.

How polymorphic components work in a web application ?

Objectif

Le but est simple : avoir un composant bouton capable de changer d'élément html à sa racine.

Par exemple, un bouton peut avoir le comportement d’un lien ou d’un bouton, et donc avoir les attributs de son élément à la racine.

home.tsx
1import { Button } from "@/ui/design-system/button";
2import Link from "next/link";
3
4const Home = () => {
5 return (
6 <>
7 <Button as={Link} href={"/test"}>
8 internal link
9 </Button>
10 <Button
11 as={"a"}
12 href={"https://www.example.com"}
13 rel="noreferrer"
14 target="_blank"
15 >
16 external link
17 </Button>
18 <Button>button</Button>
19 </>
20 );
21};
22
23export default Home;

Bien que nous ayons des composants avec un style similaire, si on inspecte le DOM, les éléments à la racine du composant bouton sont bien différents.

Inspected DOM with 3 polymorphic buttons

Avantages

La création d’un composant polymorphe présente plusieurs avantages :

  • Cohérence de la structure HTML : pas de button et de a imbriqués les uns dans les autres, les crawlers interprètent correctement la structure de l’application web.
  • Flexibilité : en plus des éléments button et a, le composant peut prendre n’importe quel élément, par exemple une div avec un role=”button”.
  • Robustesse : grâce à Typescript, selon l’élément renseigné, mon IDE interprète correctement les attributs attendus (un lien a un href mais un bouton n’en a pas)
hexa web logo
Hexa web
Des conseils pour un projet web ?
Nous contacter

1. Création du composant bouton

La première étape est de créer un bouton :

button.tsx
1type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
2
3export const Button = ({ ...props }: Props) => {
4 return <button {...props} />;
5};

Ici, le composant bouton attend des attributs similaires aux attributs HTML en props. Il n’est pas dynamique et son élément à la racine est obligatoirement un button.

2. Ajouter le style du bouton

La suite logique est d’ajouter du style au bouton pour correspondre à un potentiel design system.

Nous allons utiliser CVA avec TailwindCSS et tailwind-merge. Vous pouvez consulter notre tutoriel pour intégrer un design system avec CVA.

button.tsx
1import { twMerge } from "tailwind-merge";
2import { cva, type VariantProps } from "class-variance-authority";
3
4const button = cva(["font-medium", "py-2.5", "px-3.5", "rounded-md"], {
5 variants: {
6 intent: {
7 primary: ["bg-blue-500", "text-white"],
8 secondary: ["bg-black", "text-white"],
9 ghost: ["text-blue-500"],
10 },
11 },
12 defaultVariants: {
13 intent: "primary",
14 },
15});
16
17type Props = React.ButtonHTMLAttributes<HTMLButtonElement> &
18 VariantProps<typeof button>;
19
20export const Button = ({ className, intent, ...props }: Props) => (
21 <button
22 className={twMerge(button({ className, intent }))}
23 {...props}
24 />
25);

Le composant button peut maintenant prendre un intent en props. Il ne reste plus qu’à gérer l’aspect polymorphe du bouton.

3. Modifier l’attribut as pour prendre en paramètre un élément

Maintenant, il faut pouvoir renseigner l’élément que l’on souhaite utiliser à la racine du composant.

La convention veut que nous utilisions l’attribut as={Element}. Il faut donc Omit le tag as qui existe par défaut pour certains éléments HTML et attendre un type Element.

button.tsx
1import { twMerge } from "tailwind-merge";
2import { cva, type VariantProps } from "class-variance-authority";
3
4const button = cva(["font-medium", "py-2.5", "px-3.5", "rounded-md"], {
5 variants: {
6 intent: {
7 primary: ["bg-blue-500", "text-white"],
8 secondary: ["bg-black", "text-white"],
9 ghost: ["text-blue-500"],
10 },
11 },
12 defaultVariants: {
13 intent: "primary",
14 },
15});
16
17type Props = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "as"> & {
18 as?: Element;
19} & VariantProps<typeof button>;
20
21export const Button = ({ className, intent, ...props }: Props) => (
22 <button className={twMerge(button({ className, intent }))} {...props} />
23);
hexa web logo
Hexa web
Des conseils pour un projet web ?
Nous contacter

4. Utiliser les types dynamiques

Enfin, il ne reste plus qu’à changer dynamiquement l’élément à la racine et interpréter intelligemment les types attendus selon chaque élément.

Par défaut, notre composant est un bouton, nous gardons donc cet élément si as n’est pas renseigné lors de l’instanciation du composant.

button.tsx
1import { twMerge } from "tailwind-merge";
2import { cva, type VariantProps } from "class-variance-authority";
3
4// Récupérer les types dynamiquement selon l'élément en retirant le as par défaut
5type PolymorphicProps<Element extends React.ElementType, Props> = Props &
6 Omit<React.ComponentProps<Element>, "as"> & {
7 as?: Element;
8 };
9
10// Ajouter du style au bouton
11const button = cva(["font-medium", "py-2.5", "px-3.5", "rounded-md"], {
12 variants: {
13 intent: {
14 primary: ["bg-blue-500", "text-white"],
15 secondary: ["bg-black", "text-white"],
16 ghost: ["text-blue-500"],
17 },
18 },
19 defaultVariants: {
20 intent: "primary",
21 },
22});
23
24// Récupérer les variants grâce à CVA, en l'occurence l'intent
25type Props = VariantProps<typeof button>;
26
27// Définir l'élément par défaut si as n'est pas renseigné
28const defaultElement = "button";
29
30// Composant Button correctement typé et stylisé
31export const Button = <
32 Element extends React.ElementType = typeof defaultElement
33>(
34 props: PolymorphicProps<Element, Props>
35) => {
36 const { as: Component = defaultElement, className, intent, ...rest } = props;
37 return (
38 <Component className={twMerge(button({ className, intent }))} {...rest} />
39 );
40};
Consulter le code
Le code de cet article est disponible, vous pouvez le consulter sur GitHub.

Conclusion

La création d'un bouton polymorphe avec Typescript pour React / Next.js en utilisant TailwindCSS offre une solution élégante pour maintenir la cohérence du style et la flexibilité dans le choix des éléments HTML.

Avec cette approche, la structure HTML reste propre et compréhensible par les crawlers, améliorant l'accessibilité et l'UX.

De plus, l’utilisation de Typescript permet de bénéficier des avantages que nous avons listé dans notre article Pourquoi utiliser Typescript dans un project React & Next.js ?.

Avec la combinaison d’un bouton polymorphe et d’une approche de style avec CVA, ce composant est robuste, flexible et évolutif.

Échangeons sur votre projet web

Présentez-nous votre projet web, nous vous recontacterons dans les prochaines 24h.