Skip to content

pagopa/pagopa-ui

unversioned · 4201cf9f943cUNLICENSED

The PagoPA web design system as React components, with the skill and the command line that tell an agent which component renders a Figma node.

@pagopa/ui

PagoPA's React design system, published through GitHub Packages at https://npm.pkg.github.com (not the npm registry). The first package release is pending.

Install

Configure the @pagopa scope in your .npmrc and authenticate with a GitHub classic personal access token that has read:packages. Store the token in an environment variable instead of committing it:

@pagopa:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}

After a version is released, install the package alongside React and ReactDOM:

pnpm add @pagopa/ui

Import the stylesheet once and wrap the component tree in UIProvider:

import "@pagopa/ui/styles.css"
import { Button, UIProvider } from "@pagopa/ui"

export function App() {
  return (
    <UIProvider>
      <Button>Continua</Button>
    </UIProvider>
  )
}

The provider renders the scope root the stylesheet is written against; a component tree outside it is unstyled. Scoped styles require @scope support: Chrome 118+, Safari 17.4+ or Firefox 128+. There is no fallback, so older browsers render the components unstyled.

React and ReactDOM are peer dependencies. The package ships compiled CSS, fonts, artwork, and TypeScript declarations; consumers do not need Tailwind, shadcn, or the repository's build aliases.

Tokens

The --figma-* custom properties in styles.css are the public token contract: a section that renders beside the components reads the same colours, spacing, radius and shadows. @pagopa/ui/tokens exports them as typed data, and @pagopa/ui/tokens.css ships the block alone for a page that does not load the component stylesheet. The values are read-only; a rename is a breaking change listed in the changelog. See the tokens guide for the loading options and the two examples.

Router links

Every navigation link renders as a plain <a> unless a provider supplies the application's link component. With Next.js, define the adapter in a Client Component: the provider value contains functions and cannot cross the Server/Client boundary.

// app/providers.tsx
"use client"

import * as React from "react"
import Link from "next/link"
import { UIProvider, type LinkComponentProps } from "@pagopa/ui"

const RouterLink = React.forwardRef<HTMLAnchorElement, LinkComponentProps>(function RouterLink(
  { href, ...props },
  ref,
) {
  return <Link {...props} href={href} ref={ref} />
})

export function Providers({ children }: { children: React.ReactNode }) {
  return <UIProvider linkComponent={RouterLink}>{children}</UIProvider>
}

The component receives href, the anchor attributes and a ref; keep it forwarding all of them so client-side routing, open-in-new-tab and which-link focus survive. In-page #id links stay plain anchors.

Next.js (App Router)

The package ships one ES module per component, and each module that runs in the browser carries its own "use client". A Server Component imports the components it needs directly and pulls no wrapper module of its own; a page loads only the modules it imports, not the whole library. Import the stylesheet once in the root layout and render the provider inside <body>, so every page keeps the link component and the texts:

// app/layout.tsx
import "@pagopa/ui/styles.css"
import type { ReactNode } from "react"
import { Providers } from "./providers"

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="it">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Keep locale and messages in the same Client Component as linkComponent; a dictionary can contain functions, such as the pagination labels.

Languages and texts

The default dictionary is Italian. A text prop wins over the provider, and the provider wins over the default dictionary. Switch a whole tree to English:

import { UIProvider, enMessages } from "@pagopa/ui"

;<UIProvider locale="en-US" messages={enMessages}>
  {children}
</UIProvider>

locale is the BCP 47 code the components format with; calendarLocale takes a react-day-picker locale. Override a single label, leaving the rest Italian:

<UIProvider messages={{ pagination: { previousLabel: "Indietro" } }}>

A nested provider merges over the enclosing one. itMessages and enMessages are exported whole; Messages types a complete dictionary.

Figma handoff

figma-map.json ships with the package and answers which exports render a Figma node. The pagopa-ui-map command that ships beside it reads one row at a time, offline, so a forty-part layout costs forty lookups instead of a read of the whole map:

pagopa-ui-map resolve 194:3654   # the exports that render this node, and its status
pagopa-ui-map search alert       # nodes by name, export, story id or node id
pagopa-ui-map export Switch      # the nodes one export renders

Every command accepts --json. Node 22.18 or later runs them.

The ids are the library's: the map indexes the component file, so resolve wants the component an instance comes from, not the instance. A link to another Figma file is refused for that reason — its ids belong to that file. A row whose also line names a component without an import (Checkbox, a template) is drawn by something the package keeps internal: render the public component the row names, or build the part.

plugin.json makes the package an Agent Plugin. An agent that installs @pagopa/ui therefore also installs the figma-to-ui skill, which is the procedure that turns a layout into components: it says how a status is read, what to do when a part has no row, and when a part is a gap in the design system rather than something to build.