Skip to content

Guide

Getting started

You can choose between:

Manual setup

1. Install Open Props

Open Props v2 hasn't dropped yet, which is why this project relies on the OPv2 beta. The difference right now between the OPv1 and the OPv2 beta aren't huge (the way OPUI consumes it), so you should be fine with either version.

sh
# pnpm
pnpm add opbeta@npm:open-props@2.0.0-beta.5 -S

# npm
npm i opbeta@npm:open-props@2.0.0-beta.5 -S

2. Base setup

The setup process will differ a bit if you use a framework, but the core principles still apply. You should have no problems getting it to work though 👍 Otherwise, let me know.

This is the folder structure that comes out of the box. Feel free to change it to your needs.

├─ main.css
├─ opui.css
├─ open-props.css
├─ theme.css
├─ core
│  └─ normalize.css
│  └─ utils.css
│  └─ components.css
├─ components
│  └─ button.css
│  └─ ...
  • main.css
    is the home of all your CSS
  • opui.css
    consists of OPUI imports, only
  • open-props.css
    consists of Open Props imports, only
  • theme.css
    the default theme provided with OPUI
css
/*
This main.css file represents whatever file you choose to use as an entry point for OPUI
Ideally it's the root/entry CSS file in your project.
*/

/* Open Props imports */
@import "./open-props.css";

/* OPUI Core */
@import "./opui.css";

/* Theme (example) */
/* @import "./theme.css"; */
css
@layer openprops, normalize, utils, theme, components.root, components.extended;

/* Normalize */
@import "./core/normalize.css";

/* Utils */
@import "./core/utils.css";

/* Components */
@import "./core/components.css";
css
/*
* Open Props
* Import as many props as you need here.
* https://unpkg.com/browse/open-props@2.0.0-beta.5/css
*/
@import "open-props/css/media-queries.css";
@import "open-props/index.css" layer(openprops);
@import "open-props/css/sizes/media.css" layer(openprops);
@import "open-props/css/font/lineheight.css" layer(openprops);
@import "open-props/css/color/hues.oklch.css" layer(openprops);
css
/*
OPUI default theme
*/
@layer theme {
  .light {
    --color-scheme: light;
  }
  .dark {
    --color-scheme: dark;
  }

  :where(html) {
    color-scheme: var(--color-scheme, light dark);

    --palette-hue: var(--oklch-teal);
    --palette-hue-rotate-by: 5;
    --palette-chroma: 0.89;

    /* Primary */
    --primary: var(--color-8);
    --primary-light: oklch(from var(--primary) calc(l * 1.25) c h);
    --primary-dark: oklch(from var(--primary) calc(l * 0.75) c h);
    --primary-contrast: var(--gray-1);

    /* Text */
    --text-color-1: light-dark(var(--gray-15), var(--gray-1));
    --text-color-1-contrast: light-dark(var(--gray-2), var(--gray-15));
    --text-color-2: light-dark(var(--gray-13), var(--gray-4));
    --text-color-2-contrast: light-dark(var(--gray-4), var(--gray-13));

    /* Surface */
    --surface-default: light-dark(var(--gray-1), var(--gray-13));
    --surface-filled: light-dark(var(--gray-3), var(--gray-15));
    --surface-tonal: light-dark(var(--gray-3), var(--gray-12));
    --surface-elevated: light-dark(var(--gray-1), var(--gray-12));

    /* Shadows */
    --shadow-color: light-dark(220 3% 15%, 220 40% 2%);
    --shadow-strength: light-dark(1%, 10%);
    --inner-shadow-highlight: light-dark(
      inset 0 -0.5px 0 0 #fff,
      inset 0 0.5px 0 0 #0001,
      inset 0 -0.5px 0 0 #fff1,
      inset 0 0.5px 0 0 #0007
    );

    /* Typography */
    --font-size-h1: var(--font-size-fluid-3, 3.5rem);
    --font-size-h2: var(--font-size-fluid-2, 2rem);
    --font-size-h3: var(--font-size-fluid-1, 1.5rem);
    --font-size-h4: var(--font-size-3, 1.25rem);
    --font-size-h5: var(--font-size-2, 1.1rem);
    --font-size-h6: var(--font-size-fluid-0, 1rem);
    --font-size-lg: var(--font-size-3, 1.25rem);
    --font-size-md: var(--font-size-fluid-0, 1rem);
    --font-size-sm: 0.875rem;
    --font-size-xs: var(--font-size-0, 0.75rem);

    /* Borders */
    --border-color: light-dark(var(--gray-4), var(--gray-12));
    --border-radius: var(--size-1);
    --border-width: 1px;

    /* Input Field */
    --field-border-color: var(--border-color);
    --field-border-radius: var(--size-1);
    --field-border-width: 1px;
    --field-size: 2.3lh;
    --field-size-small: 1.9lh;

    /* Button */
    --button-border-radius: var(--radius-round);
    /* Ripple effect */
    @media (prefers-reduced-motion: no-preference) {
      --button-ripple-size: 100%;
      --button-ripple-duration: 0.5s;
    }
  }

  /* Highlight colors */
  :where(.red, .error, del) {
    --palette-hue: var(--oklch-red, 25);
    --palette-chroma: 1;
    --palette-hue-rotate-by: 1;
  }
  :where(.blue, .ok, abbr, dfn) {
    --palette-hue: var(--oklch-blue, 210);
    --palette-chroma: 1;
    --palette-hue-rotate-by: 1;
  }
  :where(.green, .good, ins) {
    --palette-hue: var(--oklch-green, 145);
    --palette-chroma: 1;
    --palette-hue-rotate-by: 1;
  }
  :where(.orange, .warning) {
    --palette-hue: var(--oklch-orange, 75);
    --palette-chroma: 1;
    --palette-hue-rotate-by: 1;
  }

  :where(html) {
    --red: oklch(from var(--color-9) l 0.2 25);
    --blue: oklch(from var(--color-9) l 0.2 210);
    --green: oklch(from var(--color-9) l 0.2 145);
    --orange: oklch(from var(--color-7) l 0.2 75);
  }

  /* Gray palette */
  :where(html) {
    --gray-chroma: 0.01;
    --gray-lightness: 255;

    --gray-1: oklch(
      from var(--color-1) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-2: oklch(
      from var(--color-2) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-3: oklch(
      from var(--color-3) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-4: oklch(
      from var(--color-4) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-5: oklch(
      from var(--color-5) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-6: oklch(
      from var(--color-6) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-7: oklch(
      from var(--color-7) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-8: oklch(
      from var(--color-8) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-9: oklch(
      from var(--color-9) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-10: oklch(
      from var(--color-10) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-11: oklch(
      from var(--color-11) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-12: oklch(
      from var(--color-12) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-13: oklch(
      from var(--color-13) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-14: oklch(
      from var(--color-14) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-15: oklch(
      from var(--color-15) l var(--gray-chroma) var(--gray-lightness)
    );
    --gray-16: oklch(
      from var(--color-16) l var(--gray-chroma) var(--gray-lightness)
    );
  }
}
src/core
css
@layer normalize {
  *,
  ::before,
  ::after {
    box-sizing: inherit;
  }

  * {
    scrollbar-width: thin;
  }

  :where(html) {
    --_page-bg-color: var(--surface-default);

    accent-color: var(--primary);
    background-color: var(--_page-bg-color);
    block-size: 100%;
    box-sizing: border-box;
    caret-color: var(--primary);
    color: var(--text-color-2);
    font-family: var(--font-sans);
    interpolate-size: allow-keywords;
    line-height: var(--font-lineheight-4);

    /* https://kilianvalkhof.com/2022/css-html/your-css-reset-needs-text-size-adjust-probably/ */
    -moz-text-size-adjust: none;
    -webkit-text-size-adjust: none;
    text-size-adjust: none;

    @media (--motionOK) {
      scroll-behavior: smooth;
    }
  }

  :where(body) {
    -moz-osx-font-smoothing: grayscale;
    -webkit-font-smoothing: antialiased;
    container-type: inline-size;
    font-size: 16px;
    font-synthesis: style;
    font-weight: 400;
    inline-size: 100%;
    margin: 0;
    min-block-size: 100%;
    min-inline-size: 320px;
    position: relative;
    text-rendering: optimizeLegibility;
  }

  /* TODO */
  :where(:not(dialog, popover)) {
    margin: 0;
  }

  :where(:not(fieldset, progress, meter)) {
    background-origin: border-box;
    background-repeat: no-repeat;
    border-style: solid;
    border-width: 0;
  }

  :where(fieldset) {
    border: var(--field-border-width) solid var(--field-border-color);
    border-radius: var(--field-border-radius);
    padding: var(--size-3);
    display: grid;
    gap: var(--size-3);
  }

  :where(input, button, textarea),
  :where(input[type="file"])::-webkit-file-upload-button {
    color: inherit;
    font-size: inherit;
    font: inherit;
    letter-spacing: inherit;
  }

  :where(input):-webkit-autofill,
  :where(input):-webkit-autofill:hover,
  :where(input):-webkit-autofill:focus,
  :where(textarea):-webkit-autofill,
  :where(textarea):-webkit-autofill:hover,
  :where(textarea):-webkit-autofill:focus,
  :where(select):-webkit-autofill,
  :where(select):-webkit-autofill:hover,
  :where(select):-webkit-autofill:focus,
  :where(input):autofill,
  :where(input):autofill:hover,
  :where(input):autofill:focus,
  :where(textarea):autofill,
  :where(textarea):autofill:hover,
  :where(textarea):autofill:focus,
  :where(select):autofill,
  :where(select):autofill:hover,
  :where(select):autofill:focus {
    -webkit-text-fill-color: var(--text-color-2);
    -webkit-box-shadow: 0 0 0px 1e5px var(--well-1) inset;
    transition: background-color 5000s ease-in-out 0s;
  }

  ::placeholder {
    color: var(--text-color-2);
  }

  ::-moz-placeholder {
    opacity: 1;
  }

  :focus-visible {
    /* Inverts the --_page-bg-color */
    --_focus-visible-color: rgb(
      from var(--_page-bg-color) calc(255 - r) calc(255 - g) calc(255 - b)
    );

    border-radius: var(--border-radius, 0px);
    outline: 2px solid var(--_focus-visible-color);
    outline-offset: 2px;
  }

  @media (--motionOK) {
    :where(:focus-visible) {
      transition: outline-offset 145ms var(--ease-2);
    }
    :where(:not(:active):focus-visible) {
      transition-duration: 0.15s;
    }
  }

  :where(:not(:active):focus-visible) {
    outline-offset: var(--outline-offset, 0px);
  }

  :where(
      a[href],
      area,
      button,
      input:not(
          [type="text"],
          [type="email"],
          [type="number"],
          [type="password"],
          [type=""],
          [type="tel"],
          [type="url"]
        ),
      label[for],
      select,
      summary
    ) {
    cursor: pointer;
  }

  :where(
      a[href],
      area,
      button,
      [role="button"],
      input,
      label[for],
      select,
      summary,
      textarea,
      [tabindex]:not([tabindex*="-"])
    ) {
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;
  }

  :where(img, svg, video, canvas, audio, iframe, embed, object) {
    display: block;
  }

  :where(img, svg, video) {
    block-size: auto;
    max-inline-size: 100%;
  }

  :where(svg:not([width])) {
    inline-size: var(--size-7);
  }

  :where(dt:not(:first-of-type)) {
    margin-block-start: var(--size-5);
  }

  :where(figure) {
    display: grid;
    gap: var(--size-2);
    place-items: center;
  }

  :target {
    scroll-margin-block-start: 2rem;
  }
}
css
@layer utils {
  /*
Screen-reader only
When you visibly want to hide an element but make it accessible for screen readers.
*/
  .sr-only {
    block-size: 1px;
    clip-path: inset(50%);
    inline-size: 1px;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
  }

  /* Hover and active effect for checkbox, radio and icon buttons */
  :where(.checkbox input, .radio input, .icon-button) {
    --isLTR: 1;
    --isRTL: -1;

    position: relative;
    transform-style: preserve-3d;

    &:dir(rtl) {
      --isLTR: -1;
      --isRTL: 1;
    }

    &:where(:not([disabled])) {
      &:hover:before {
        --thumb-scale: 1;
      }

      &:active:before {
        --thumb-scale: 1.1;
      }

      &::before {
        --thumb-scale: 0.01;
        --highlight-size: 150%;

        background-color: oklch(0.6 0 0 / 0.2);
        block-size: var(--highlight-size);
        clip-path: circle(50%);
        content: "";
        inline-size: var(--highlight-size);
        inset-block-start: 50%;
        inset-inline-start: 50%;
        position: absolute;
        transform-origin: center center;
        transform: translateX(calc(var(--isRTL) * 50%)) translateY(-50%)
          translateZ(-1px) scale(var(--thumb-scale));
        will-change: transform;

        @media (prefers-reduced-motion: no-preference) {
          transition: transform 0.2s ease;
        }
      }
    }
  }
}
css
/*
* Components are divided into two categories - if they are stand-alone (root) or if they are built on top of others (extended).
*/

/*** Root components (no dependencies)  */
@import "../components/button.css";
@import "../components/icon-button.css";
@import "../components/tab-buttons.css";
@import "../components/toggle-button-group.css";
@import "../components/avatar.css";
@import "../components/badge.css";
@import "../components/card.css";
@import "../components/chip.css";
@import "../components/definition-list.css";
@import "../components/divider.css";
@import "../components/link.css";
@import "../components/table.css";
@import "../components/progress.css";
@import "../components/spinner.css";
@import "../components/checkbox-radio.css";
@import "../components/switch.css";
@import "../components/range.css";
@import "../components/typography.css";

/*** Extended components (has dependencies) */
@import "../components/button-group.css";
@import "../components/accordion.css";
@import "../components/list.css";
@import "../components/alert.css";
@import "../components/dialog.css";
@import "../components/snackbar.css";
@import "../components/field-group.css";
@import "../components/field.css";
@import "../components/select.css";
@import "../components/text-field.css";
@import "../components/textarea.css";
@import "../components/rich-text.css";

3. Copy & paste

Browse all the components.

Copy and paste the HTML and CSS (see the "Installation" section on each component page) and you're good to go!

NPM installation

sh
# pnpm
pnpm add opui-css -S

# npm
npm i opui-css -S

Then check your node_modules folder for the opui-css package and pick and choose what files you want to use!

Bundled files

The dist folder includes bundled files with:

  • dist/op.css all the needed Open Props imports
  • dist/ui.css the entire OPUI library
  • dist/op+ui.css both files above combined

CDN

https://cdn.jsdelivr.net/npm/opui-css/