Baptiste Drillien
Baptiste Drillien

Développeur Next.js - Freelance

Disponible - Me contacter

Créer un bouton polymorphe avec TypeScript, React et Next.js

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.

Comment fonctionnent les composants polymorphes
Comment fonctionnent les composants polymorphes

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
import { Button } from "@/ui/design-system/button";
import Link from "next/link";

const Home = () => {
return (
<>
<Button as={Link} href={"/test"}>
internal link
</Button>
<Button
as={"a"}
href={"https://www.example.com"}
rel="noreferrer"
target="_blank"
>
external link
</Button>
<Button>button</Button>
</>
);
};

export default Home;

baptistedri/polymorphic-components

Exemple de composant bouton polymorphe avec Next.js, Typescript, CVA et TailwindCSS

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.

Inspection du DOM avec 3 boutons polymorphes
Inspection du DOM avec 3 boutons polymorphes

Avantages

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

1. Création du composant bouton

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

button.tsx
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;

export const Button = ({ ...props }: Props) => {
return <button {...props} />;
};

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 mon article sur la création d'un design system avec CVA.

button.tsx
import { twMerge } from "tailwind-merge";
import { cva, type VariantProps } from "class-variance-authority";

const button = cva(["font-medium", "py-2.5", "px-3.5", "rounded-md"], {
variants: {
intent: {
primary: ["bg-blue-500", "text-white"],
secondary: ["bg-black", "text-white"],
ghost: ["text-blue-500"],
},
},
defaultVariants: {
intent: "primary",
},
});

type Props = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof button>;

export const Button = ({ className, intent, ...props }: Props) => (
<button className={twMerge(button({ className, intent }))} {...props} />
);

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
import { twMerge } from "tailwind-merge";
import { cva, type VariantProps } from "class-variance-authority";

const button = cva(["font-medium", "py-2.5", "px-3.5", "rounded-md"], {
variants: {
intent: {
primary: ["bg-blue-500", "text-white"],
secondary: ["bg-black", "text-white"],
ghost: ["text-blue-500"],
},
},
defaultVariants: {
intent: "primary",
},
});

type Props = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "as"> & {
as?: Element;
} & VariantProps<typeof button>;

export const Button = ({ className, intent, ...props }: Props) => (
<button className={twMerge(button({ className, intent }))} {...props} />
);

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
import { twMerge } from "tailwind-merge";
import { cva, type VariantProps } from "class-variance-authority";

// Récupérer les types dynamiquement selon l'élément en retirant le as par défaut
type PolymorphicProps<Element extends React.ElementType, Props> = Props &
Omit<React.ComponentProps<Element>, "as"> & {
as?: Element;
};

// Ajouter du style au bouton
const button = cva(["font-medium", "py-2.5", "px-3.5", "rounded-md"], {
variants: {
intent: {
primary: ["bg-blue-500", "text-white"],
secondary: ["bg-black", "text-white"],
ghost: ["text-blue-500"],
},
},
defaultVariants: {
intent: "primary",
},
});

// Récupérer les variants grâce à CVA, en l'occurence l'intent
type Props = VariantProps<typeof button>;

// Définir l'élément par défaut si as n'est pas renseigné
const defaultElement = "button";

// Composant Button correctement typé et stylisé
export const Button = <
Element extends React.ElementType = typeof defaultElement
>(
props: PolymorphicProps<Element, Props>
) => {
const { as: Component = defaultElement, className, intent, ...rest } = props;
return (
<Component className={twMerge(button({ className, intent }))} {...rest} />
);
};

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 Dimitri Dumont a listés dans son 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.

baptistedri/polymorphic-components

Exemple de composant bouton polymorphe avec Next.js, Typescript, CVA et TailwindCSS