CSS logical properties : la révolution pour l’internationalisation
Lors d’une mission récente j’ai récemment dû implémenter un chatbot. C’était une première et je me dis que c’est l’occasion parfaite pour vous partager mon expérience et des conseils si vous aussi passez par là.
Avoir un chatbot facilement utilisable sur n’importe quelle page, léger, performant et personnalisable, selon les besoins du client.
En terme d’usage voici ce que je visais :
<body>
<chatbot
toggle-button-text="Fragrance advisor"
chatbot-header-title-text="Fragrance advisor"
/>
<script type="module" src="./dist/chatbot.js"></script>
</body>
Mon seul objectif : avoir un chatbot fonctionnel sur n’importe quelle page du site. Je me suis donc dirigé vers un web component. Me restait maintenant à décider comment j’allais créer ce web component. Tout faire nativement ? Utiliser un framework comme Lit ou Svelte ?
J’ai opté pour le combo Svelte 5 + SvelteKit puisque j’avais déjà eu une bonne expérience avec Svelte 4 l’année passée pour un projet personnel.
Une API REST et son schema OpenAPI étaient à ma disposition. Je pouvais donc facilement accéder à la session de l’utilisateur, aux conversations, aux messages de la conversation et surtout le endpoint /chat (en server-sent events).
Cet endpoint /chat acceptait uniquement les données dans le body d’une requête en POST. Malheureusement, l’API EventSource qui nous permet d’utiliser les server-sent events dans le navigateur ne prend pas de paramètre dans le body. J’ai donc dû utiliser ce package maintenu par Microsoft : fetch-event-source.
Le schema OpenAPI étant disponible, j’ai utilisé openapi-fetch et openapi-typescript pour typer tous mes endpoints et bénéficier de l’autocomplétion Intellisense dans VS Code.
Je vous recommande Bruno, une alternative open-source à Postman, que j’utilise pour facilement tester mes requêtes ailleurs que dans mon environnement de dev.
Je voulais fournir un chatbot aussi isolé et autonome que possible. Quand on développe un web component il faut garder en tête que son CSS est presque entièrement isolé du reste de la page. Presque vous dites ? Et oui, il existe une liste d’irréductibles propriétés qui fuitent encore et toujours dans notre Shadow DOM (Source). C’est d’ailleurs une bonne chose puisqu’un web component doit normalement hériter du branding et donc des font-family définies sur la page.
Si on a ce DOM…
<div class="chatbot-wrapper">
<chatbot />
</div>
… alors une variable CSS --padding définie en global et une propriété color définie sur l’élément parent seront héritées par notre <chatbot> :
/* CSS from the page containing the web component */
:root {
--padding: 1rem;
}
.chatbot-wrapper {
color: red;
}
/* CSS of our web component */
:host {
/*
Reset all CSS properties.
In our case to prevent the
color of our web component
to be `red`;
*/
all: initial;
padding: var(--padding); /* 1rem */
}
all: initial au style de reset de votre web componentEn ajoutant all: initial aux styles de base de notre Shadow DOM on s’assure de ne pas avoir de mauvaise surprise une fois notre composant en production.

Utilisez un textarea au lieu d’un input pour pouvoir écrire du texte formaté avec des sauts de ligne (Shift + Enter).
<textarea
bind:this={ui.textarea}
bind:value={ui.inputValue}
placeholder={translate(userLanguage).placeholder}
rows={1}
disabled={isLoading}
oninput={autoResize}
onkeydown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onsubmit(e);
}
}}
id="chat-input"
></textarea>
En complément, permettez à la hauteur du textarea de grandir en fonction du contenu:
field-sizing: content; max-block-size: 7lh; resize: none; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--grey-light) transparent;
La propriété field-sizing n’est pas encore disponible sur Firefox ni Safari. Vous devrez ajouter ce comportement via JavaScript en complément :
const autoresize = CSS.supports("field-sizing: content") ? null : function (e: Event) {
const textarea = e.target as HTMLTextAreaElement;
textarea.style.height = textarea.scrollHeight + "px";
}
Avec server-sent events, les messages reçus par le endpoint /chat sont divisés en chunk. Au lieu d’attendre d’avoir reçu tous les chunks pour afficher le message final et faire attendre l’utilisateur pour rien, pensez à afficher ces chunks à l’utilisateur dés leur réception pour avoir une impression d’écriture progressive du message par le chatbot.
Aussi, scrollez la zone de chat vers le bas lorsque vous recevez des chunks de l’endpoint /chat pour garder la dernière partie du message toujours visible à l’écran.
Une fois le message reçu ajoutez le focus sur le textarea pour permettre à l’utilisateur de directement écrire une réponse sans avoir à cliquer.
Il semblerait que la manière la plus efficace à l’heure actuelle est de rajouter un élément dans le bloc parent scrollable contenant nos messages
Avec Svelte, la rune $effect et l’API scroll into view voilà le résultat :
let ui = $state({
isLoading: false,
textarea: null as HTMLTextAreaElement | null,
scrollAnchor: null as HTMLDivElement | null,
});
function scrollToBottom() {
if (ui.scrollAnchor) {
requestAnimationFrame(() => {
ui.scrollAnchor?.scrollIntoView({
behavior: "smooth",
block: "end",
});
});
}
}
$effect(() => {
if (messages.length > 0) {
// Handle scrolling
scrollToBottom();
// Handle focus only when loading completes
if (!ui.isLoading && ui.textarea) {
requestAnimationFrame(() => {
ui.textarea?.focus();
});
}
}
});
Dans mon cas le chatbot prend toute la hauteur de la page. Donc on doit permettre aux développeurs en charge du site de désactiver/activer le scroll sur la page lorsque le chatbot est ouvert/fermé.
On a certes pas la main sur la page accueillant l’instance de notre web component, mais on peut néanmoins fournir un custom event (CustomEvent API) lorsque notre composant s’ouvre et se ferme via la méthode dispatchEvent.
/**
* Emit a custom event
* (c) Chris Ferdinandi, MIT License, https://gomakethings.com
* @param {String} type - The event type
* @param {Object} detail - Any details to pass along with the event
* @see https://gist.github.com/cferdinandi/58d272dd09ef4fe76609b7ae9be89c58#file-index-html-L462-L480
*/
function emit(type: string, detail = {}) {
let event = new CustomEvent(`chatbot:${type}`, {
bubbles: true,
cancelable: true,
detail: detail,
});
const rootElement = document.querySelector("chatbot");
rootElement?.dispatchEvent(event);
}
function openChatbot() {
chatbotOpen = true;
textarea?.focus();
emit("change-open-state", { open: chatbotOpen });
}
function closeChatbot() {
chatbotOpen = false;
emit("change-open-state", { open: chatbotOpen });
}
Dans mon cas le chatbot ne fournissait pas de système d’authentification. Mais l’API me fournissait des endpoints pour créer une session, des conversations, et récupérer les messages d’une conversation.
const initChat = async () => {
const sessionCookie = getCookie("session-id");
if (!sessionCookie) {
sessionId = `session_${generateUUID()}_${Date.now()}`;
createSession(sessionId);
const duration = 7 * 24 * 60 * 60;
const expires = new Date(Date.now() + duration * 1000).toUTCString();
document.cookie = `session-id=${sessionId}; path=/; expires=${expires}; Secure; SameSite=Strict;`;
} else {
sessionId = sessionCookie;
const session = await getSession(sessionId);
const conversations = await getConversations(session.id);
// If we get conversations we retrieve
// the most recent one and display
// its messages in the chat
}
};
Le chatbot devait être disponible dans plusieurs langues. Son contenu est partagé entre le contenu statique et la conversation elle-même.
Dans mon cas, le contenu statique était composé :
aria-label sur les icons en SVG<label> du <textarea>Le texte des empty states et de la politique de confidentialité change en fonction de la langue du navigateur de l’utilisateur.
J’ai préconisé un simple objet JavaScript regroupant toutes mes locales et leurs clés respectives pour gérer la traduction de ces contenus.
Voici un exemple simplifié :
export type SupportedLocale = "en" | "fr";
export const supportedLocales: SupportedLocale[] = ["en", "fr"];
const translations: Record<string, Record<string, string>> = {
en: {
welcome: "How may I help you?",
},
fr: {
welcome: "Comment puis-je vous aider ?",
},
};
export function translate(locale: SupportedLocale) {
const safeLocale = supportedLocales.includes(locale)
? (locale as SupportedLocale)
: "en";
return translations[safeLocale] as Record<string, string>;
}
Dans mon composant Svelte je pouvais ensuite utiliser la fonction translate en passant la locale du navigateur et la clé souhaitée :
<p class="chatbot-fragrance__welcome-text">
{translate(userLanguage).welcome}
</p>
Le contenu de la conversation étant généré via le backend, je n’avais uniquement qu’à fournir la langue du navigateur au backend dans le endpoint /chat pour définir la langue de la conversation.
Le développement de ce web component via Svelte 5 et SvelteKit s’est déroulé sans encombres. J’aime la simplicité de Svelte et notamment leur documentation très fournie. Je vous conseille d’ailleurs leur MCP fraîchement sorti.
J’espère que ce retour d’expérience et ces quelques conseils vous seront utiles.
Et sur ce je vous dis à tantôt 🖖