This guide explains how to implement and use Kesko Design System’s components across different technologies such as React, Next.js, or plain HTML.

Each component in the Kesko Design System wraps a native HTML element or a custom element such as <k-stack> and styles it through ordinary attributes. This makes it possible to use the same components with React, Next.js, or no framework at all.

Here is a minimal example:

import { Button, Stack } from "@kesko/components";
import { IconBasketAddTo } from "@kesko/iconography/react";

export default () => (
  <Stack direction="row" gap="2xs" align="center">
    <Button>
      <IconBasketAddTo /> Add to basket
    </Button>
    <Button appearance="outline">Cancel</Button>
  </Stack>
);

Try it in the playground


Features #

  • Extremely lightweight: No extra runtime to download. 0.9 kB per component on average, styles included.
  • Standards based: Built on native elements, attributes and CSS custom properties, nothing proprietary.
  • Accessible by default: Semantic HTML foundation with support for assistive technology built in.
  • Progressively enhanced: Renders HTML and CSS first; JavaScript only adds what the platform lacks.
  • SSR friendly: Renders on the server and hydrates in the browser, no setup needed.
  • No lock-in: Fits any stack at Kesko, with or without a framework.

Quick start #

There are two necessary parts to using Kesko components. The @kesko/components package and the CSS Framework are required. Everything else is optional:

npm install @kesko/components @kesko/css

Once installed, load the CSS Framework in your app’s entry point or root layout:

import "@kesko/css";

Then, you can import the components you need:

import { Button } from "@kesko/components";

export function Toolbar() {
  return <Button onClick={() => save()}>Save</Button>;
}

React usage #

Kesko components are regular React components that support React 18 and later. Import them, pass props, handle events and attach refs the way you would with any other library. Nothing needs initializing. The only optional setup is the KeskoProvider for apps with client-side routing.

import { Button, Stack } from "@kesko/components";
import { IconBasketAddTo } from "@kesko/iconography/react";

export default () => (
  <Stack direction="row" gap="2xs" align="center">
    <Button>
      <IconBasketAddTo /> Add to basket
    </Button>
    <Button appearance="outline">Cancel</Button>
  </Stack>
);

Styles #

Each component ships its own CSS and imports it, so there is nothing to load per component. Load the CSS Framework once, in your entry point or global stylesheet, for the tokens, fonts and utilities the components rely on:

import "@kesko/css";

Events #

Components forward native events, so onClick, onFocus and onBlur work the way they do on the underlying HTML element. Components with their own state report changes through onChange. The Events section on each component’s docs lists what it emits.

<Button onClick={() => save()}>Save</Button>

Refs #

Components forward their ref to the underlying element, so you can focus or measure it:

import { useRef } from "react";
import { Button } from "@kesko/components";

export function Dialog() {
  const closeRef = useRef<HTMLButtonElement>(null);
  return (
    <Button ref={closeRef} onClick={close}>
      Close
    </Button>
  );
}

TypeScript #

Type declarations ship with the package, so there is nothing extra to install. Every component exports its props type, which is the way to type a wrapper or pick a prop’s allowed values:

import { Button, type ButtonProps } from "@kesko/components";

type SaveButtonProps = ButtonProps & { pending: boolean };

export function SaveButton({ pending, ...props }: SaveButtonProps) {
  return <Button loading={pending} loadingLabel="Saving" {...props} />;
}

Bundlers #

Component modules are ES modules that import their own CSS files. Vite, Next.js and the Webpack configs that frameworks ship handle both out of the box. A bare Webpack config needs a rule for CSS:

// webpack.config.js
module.exports = {
  module: {
    rules: [{ test: /\.css$/, use: ["style-loader", "css-loader"] }],
  },
};

Test runners #

Vitest runs the components as they are. Jest needs a CSS stub and permission to transform the ES modules in node_modules:

// jest.config.js
module.exports = {
  moduleNameMapper: { "\\.css$": "identity-obj-proxy" },
  transformIgnorePatterns: ["/node_modules/(?!@kesko/)"],
};

Next.js usage #

Components work in both Server and Client Components. Next.js renders them on the server and hydrates them in the browser. Only function props such as onClick or onChange need a Client Component.

// app/page.tsx
import { Button, Stack } from "@kesko/components";
import { IconBasketAddTo } from "@kesko/iconography/react";

export default function Page() {
  return (
    <Stack direction="row" gap="2xs" align="center">
      <Button>
        <IconBasketAddTo /> Add to basket
      </Button>
      <Button appearance="outline">Cancel</Button>
    </Stack>
  );
}

Styles #

Load the CSS Framework once, in the root layout or in your global stylesheet:

import "@kesko/css";

Routing #

Next.js treats a plain <a href> as a full page load. Add the KeskoProvider once so Kesko links navigate through the Next.js router instead.

Fonts #

Do not load Skatta Sans UI through next/font/local. The font files already ship inside @kesko/css, and next/font registers the typeface under a hashed family name that the @font-face rules cannot share. The webfonts guidelines explain the switch.


HTML usage #

Projects without React get identical styling from the CSS Framework plus the optional components stylesheet. Install both from npm or load them from the CDN:

<link rel="stylesheet" href="https://cdn.kesko.design/css/1.0.0-rc.4/kesko.min.css" integrity="sha384-gvd8gvtzb9KDXHqlb3fT5sAaEMnzau2FKelNnoKjpU0GlWZ3Xn9mbvRNxEitPWE7" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdn.kesko.design/css/1.0.0-rc.4/components.min.css" integrity="sha384-fulWdDKHyiCvTjslZ0iBDP3QibknskaqAoMOyTMJhn4O4NtWZK/9PbCR2V38gpJS" crossorigin="anonymous" />

Then copy the markup from the HTML tab of the component you need. The rules are the same ones the React wrapper follows:

  • Modifiers are attributes named exactly like the React props: variant="secondary", appearance="outline", expand, square, and so on.
  • Boolean modifiers such as expand or loading are on when the attribute is present. Writing expand="false" does not turn it off. Remove the attribute instead.
<k-stack direction="row" gap="2xs" align="center">
  <button class="k-button">Save</button>
  <button class="k-button" appearance="outline">Cancel</button>
</k-stack>

Component markup combines with the utility classes from the CSS Framework. A card with a button needs nothing beyond the two stylesheets:

Card title

Supporting text in a muted color with comfortable line height.

<div class="k-bg k-p-lg k-rounded-lg k-shadow-float">
  <h2 class="k-text k-text-2xl k-font-black k-mb-sm">Card title</h2>
  <p class="k-text-dimmer k-text-md k-leading k-mb">Supporting text in a muted color with comfortable line height.</p>
  <button class="k-button">Action</button>
</div>

Vue usage #

Vue projects use the HTML markup above inside their templates. Load the CSS Framework and the components stylesheet once in your entry point:

// main.ts
import "@kesko/css";
import "@kesko/css/components";

Then tell the compiler that k- tags are custom elements, so Vue renders them as is instead of warning about unresolved components:

// vite.config.ts
import vue from "@vitejs/plugin-vue";

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: { isCustomElement: tag => tag.startsWith("k-") },
      },
    }),
  ],
};

Component markup then works like any other template, and Vue’s default type-checking accepts the k- attributes. Bind boolean modifiers with || undefined, because Vue 3 writes false as the literal attribute value expand="false", which keeps the modifier on:

<template>
  <k-stack direction="row" gap="2xs" align="center">
    <button class="k-button" :expand="wide || undefined" @click="save">Save</button>
    <button class="k-button" appearance="outline" @click="cancel">Cancel</button>
  </k-stack>
</template>

For a link button, put the class and attributes on RouterLink. It renders an <a> and passes them through, so the link navigates through the Vue Router:

<RouterLink class="k-button" appearance="outline" to="/cart">View cart</RouterLink>

Client-side routing #

Components with an href render a real <a>, so clicking one reloads the page. In a single-page app you want the router to handle the navigation instead, but the components have no way of knowing which router you use. KeskoProvider fixes that. Wrap your app in it once, pass in your router’s navigate function, and every link will use the router:

import { KeskoProvider } from "@kesko/components";

export function App({ children }) {
  return <KeskoProvider navigate={href => router.push(href)}>{children}</KeskoProvider>;
}

The provider sits inside your router and does not replace it. It only takes over plain left clicks on same-origin links that open in the same tab. Everything else keeps its native behavior:

  • External URLs, target="_blank" and download
  • Ctrl/Cmd-clicks and disabled links
  • An onClick that calls preventDefault()

With Next.js #

A small client component keeps the useRouter hook out of the server-rendered layout:

// app/providers.jsx
"use client";

import { useRouter } from "next/navigation";
import { KeskoProvider } from "@kesko/components";

export function Providers({ children }) {
  const router = useRouter();
  return <KeskoProvider navigate={router.push}>{children}</KeskoProvider>;
}
// app/layout.jsx
import "@kesko/css";
import { Providers } from "./providers";

export default function RootLayout({ children }) {
  return (
    <html lang="fi">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

The Pages Router works the same way with useRouter from next/router. If your app sets a basePath, pass useHref so rendered links carry the prefix while the router still receives the plain path. Absolute URLs (https://…, mailto:) are never passed through useHref, so external links stay intact:

<KeskoProvider navigate={router.push} useHref={href => `/shop${href}`}>

With React Router #

Render the provider inside your router and hand it useNavigate():

import { useNavigate } from "react-router";
import { KeskoProvider } from "@kesko/components";

export function App({ children }) {
  const navigate = useNavigate();
  return <KeskoProvider navigate={navigate}>{children}</KeskoProvider>;
}

Alongside another router provider #

If another library in your app has a router provider of its own, keep it and nest ours inside with the same navigate, so links from both libraries go through the router. The example below nests both in a Next.js app with React Aria Components:

import { useRouter } from "next/navigation";
import { RouterProvider } from "react-aria-components";
import { KeskoProvider } from "@kesko/components";

export function Providers({ children }) {
  const router = useRouter();
  return (
    <RouterProvider navigate={router.push}>
      <KeskoProvider navigate={router.push}>{children}</KeskoProvider>
    </RouterProvider>
  );
}

Why a provider #

Many libraries let you swap the rendered element with an as or component prop and pass your router’s link in. We use a provider instead for the following reasons:

  • Same markup everywhere. A component renders the same element with the same attributes in React and in plain HTML. Handing rendering to another component breaks that promise.
  • Accessibility stays tested. Each component’s keyboard behavior and screen reader support are tested against the actual markup they render. An as prop would swap that element to whatever the router’s link renders.
  • One place for link behavior. Modified clicks, new tabs, downloads and external URLs are handled by the provider, the same way for every component and every router. With as, each router’s link component decides.

Localization #

Kesko Design System provides a lightweight way to localize its components. Not all components need localizing, as for the most part snippets of text are set per instance. For example, the label of a Button will likely be changed every time you use it. Those cases belong to your app.

However, some components have text which has no reason to change per instance. For example, an announcement a component makes while it is loading. For those cases, translations for Finnish, Swedish, Norwegian and English ship with the package, and components follow the lang attribute of <html>:

<html lang="sv"></html>

Nothing else is needed for those four languages. The Localization guideline covers server rendering, adding a language, and using an existing translation library.


Theming API #

Components have no theme props. They read the design tokens, and a theme is one stylesheet that overrides those tokens, so switching the brand changes every component at once. The CSS Framework ships the default Kesko theme, and loading a different brand theme from the themes package after it switches brands:

import "@kesko/css";
import "@kesko/themes/dist/k-rauta.css";

Styling API #

Every component exposes a styling API via CSS custom properties, named --k-{component}-{property} and listed in the Styles section of each component. Reach for props first; the styling API is only for the rare cases that existing props do not cover.

Here’s an example that gives the decorative color to a Button:

import type { CSSProperties } from "react";
import { Button } from "@kesko/components";

const decorative = {
  "--k-button-bg": "var(--k-color-theme-decorative)",
  "--k-button-text": "var(--k-color-text-on-decorative)",
  "--k-button-bg-hover": "var(--k-color-purple-1200)",
} as CSSProperties;

export default () => <Button style={decorative}>Decorative</Button>;

CSS custom properties inherit, so you can also set them on a parent element or on :root and every component inside follows. Here one rule on the wrapping element gives all three buttons the smaller corner radius:

.checkout {
  --k-button-radius: var(--k-radius-md);
}

Use design tokens as values rather than raw colors or sizes, so the result follows the active theme and dark mode.


Known issues #

The 62.5% trick #

Our design tokens define all font sizes in rem. A rem follows the default font size a person has set in their browser, so text, spacing and controls grow together for people who need larger text, without zooming each site.

1rem equals the browser’s default font size, which is 16px unless the person has changed it. Every rem-based package relies on that shared baseline. But if you’re doing something like this in your CSS:

html {
  font-size: 62.5%;
}

The user’s setting still applies, but the baseline moves: 1rem is now 62.5% of it, so every Kesko component renders at 62.5% of its intended size. The same happens to any other third-party package that uses rem-based sizes, and to browser extensions that inject content into the page.

The fix is to remove the override from html and express your own sizes in rem relative to the 16px default, for example 1.125rem for 18px.

We already import the fonts, tokens and themes packages, do we need the CSS framework? #

Strictly speaking, no, you don’t. Kesko CSS Framework bundles our design tokens, the base Kesko theme, the Skatta Sans UI typeface, and Tailwind-style utility classes in a single 4 kB stylesheet, so it is more about convenience and not having to install three additional packages.

That said, the framework is the path we recommend. One import keeps tokens, theme and fonts on versions that are tested together, so a release never leaves them out of step. The typeface loads once instead of once per package. And when your team reaches for the utility classes, they are already there.


Resources #