# Oyna UI: the whole documentation > A Vue 3 component library with a dark glass look. Plain CSS, no Tailwind. # Why Oyna UI I was building [invoke.wtf](https://invoke.wtf), a trainer for Invoker from Dota 2, and wanted a UI kit that looked the way I had in mind: dark glass over a rich background, no grey borders, light instead of lines, big condensed numbers, everything reachable from the keyboard. I did not find one. Most Vue UI kits look like admin panels: white cards, grey borders, the same blue button. So I drew the interface by hand. Oyna UI is that look taken out of the project and made into a library: the same surfaces, rings, type and hotkeys, without anything about the game. I made it for myself. If it suits your project, use it too. ## What is inside Plain CSS and Vue. There is no Tailwind, no UnoCSS and no CSS-in-JS: you import one stylesheet and use the components, with nothing to configure. I did not want a library that makes you adopt a styling tool to get a button. The one dependency is [Reka UI](https://reka-ui.com). Focus traps, keyboard navigation and screen reader support are hard to get right, and it already gets them right, so accessibility is not reinvented here. Everything you see is Oyna UI; Reka UI works underneath, where there is nothing to look at. ## What it is for Interfaces that want character: dashboards, tools, landing pages, side projects. It is one look, done on purpose, not a neutral base to build any design on. You can change the accent, the radius and the fonts; you cannot make it look like a white admin panel, and it has no light theme. ## Where to see it - [invoke.wtf](https://invoke.wtf) is where the look was born. The site itself is written by hand: the library came out of it, not the other way round. - The [dashboard](https://oyna-ui.org/examples/dashboard) and [settings](https://oyna-ui.org/examples/settings) examples are built only from the library's components. ## The name _Oyna_ is Uzbek for "glass", and also for "window" and "mirror". Source: https://oyna-ui.org/guide/why --- # Installation ## Install ```bash [npm] npm install oyna-ui ``` ```bash [pnpm] pnpm add oyna-ui ``` ```bash [yarn] yarn add oyna-ui ``` ```bash [bun] bun add oyna-ui ``` `vue` 3.5 or newer is a peer dependency. ## Register Import the stylesheet once and register the components: ```ts import oyna from 'oyna-ui' import { createApp } from 'vue' import App from './App.vue' import 'oyna-ui/style.css' createApp(App).use(oyna).mount('#app') ``` Or import only what you use; the rest is left out of your bundle: ```vue ``` The stylesheet is plain CSS. You need neither Tailwind nor UnoCSS. ## Fonts The look relies on two typefaces: a condensed display face for numbers and headings, and a plain sans for text. The library does not bundle font files, so loading them is one more line. Pick one of three ways. **From Google Fonts, in one import.** The quickest: ```ts import 'oyna-ui/style.css' import 'oyna-ui/fonts.css' ``` The files come from Google's servers. If that is a problem for you — privacy rules, an offline app — use the next way. **Served by you, with Fontsource.** The fonts become part of your own build: ```bash [npm] npm install @fontsource/noto-sans @fontsource/barlow-condensed @fontsource/fira-sans-condensed ``` ```bash [pnpm] pnpm add @fontsource/noto-sans @fontsource/barlow-condensed @fontsource/fira-sans-condensed ``` ```bash [yarn] yarn add @fontsource/noto-sans @fontsource/barlow-condensed @fontsource/fira-sans-condensed ``` ```bash [bun] bun add @fontsource/noto-sans @fontsource/barlow-condensed @fontsource/fira-sans-condensed ``` ```ts import '@fontsource/noto-sans/400.css' import '@fontsource/noto-sans/600.css' import '@fontsource/noto-sans/700.css' import '@fontsource/barlow-condensed/700.css' import '@fontsource/barlow-condensed/800.css' // only if your interface has Cyrillic text import '@fontsource/fira-sans-condensed/700.css' import '@fontsource/fira-sans-condensed/800.css' ``` **Your own typefaces.** Set `--o-font-sans` and `--o-font-display` to whatever you already load; see [Theming](https://oyna-ui.org/guide/theming.md). | Family | Weights | Used for | | ------------------- | ------------- | ----------------------------- | | Noto Sans | 400, 600, 700 | text | | Barlow Condensed | 700, 800 | numbers and headings | | Fira Sans Condensed | 700, 800 | Cyrillic numbers and headings | Load every weight listed: a missing weight is synthesized by the browser and looks lighter. ### Without the fonts Nothing breaks. Text falls back to the system's own sans, and headings to a condensed face the system has: Avenir Next Condensed on macOS and iOS, Arial Narrow on Windows, Roboto Condensed on Android. It is recognisably the same look, a little less sharp. The same fallbacks show for a moment while the real fonts are on their way. ## Background The glass needs something behind it. Put [`OBackground`](https://oyna-ui.org/components/background.md) at the top of your app: ```vue ``` ## Dependencies Tabs, Dialog, Select, DropdownMenu, Popover, Tooltip and PinInput are built on [Reka UI](https://reka-ui.com), which handles focus and accessibility. It is installed with the library; you don't set it up. ## What the stylesheet does to the page Besides the components, `style.css` sets on `body` a zero margin, the text font, white text and the dark background colour, and makes scrollbars thin and translucent. Nothing else is reset. ## With an AI assistant An assistant has not seen this library while it was trained, so point it at the docs written for it: - [`/llms.txt`](https://oyna-ui.org/llms.txt) — what the library is, the rules that are easy to get wrong, and a list of every page; - [`/llms-full.txt`](https://oyna-ui.org/llms-full.txt) — the whole documentation in one file; - every page of this site also exists as plain Markdown: add `.md` to its address, as in `/components/button.md`. Paste the first address into the chat, or add a line to your project's instructions for the assistant: "This project uses Oyna UI; read https://oyna-ui.org/llms.txt before writing interface code." Source: https://oyna-ui.org/guide/installation --- # Theming Every colour, radius and font is a CSS variable. Override them on `:root`, or on any element to restyle one part of the page. ```css :root { --o-accent: #4dd2ff; --o-radius: 10px; } ``` ```vue
Primary Soft A card with another accent
``` ## Tokens | Variable | Default | Meaning | | ---------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------- | | `--o-accent` | `#a5ff4d` | The one bright colour: the main thing on the screen | | `--o-danger` | `#ff8a8a` | Something at stake | | `--o-on-accent` | `#111` | Text on an accent fill | | `--o-surface` | `rgb(0 0 0 / 0.3)` | Glass | | `--o-surface-strong` | `rgb(0 0 0 / 0.4)` | Darker glass, for text-heavy content | | `--o-layer` | `rgb(20 20 26 / 0.8)` | What floats over the page: dialog, popover, menu, tooltip, toast | | `--o-layer-blur` | `16px` | How much the page is blurred under a layer; `0px` turns it off | | `--o-fill-1` … `--o-fill-4` | white at 4, 8, 12, 22 % | Stripes, controls, hover, bars | | `--o-text`, `--o-text-2`, `--o-text-3` | white at 100, 75, 50 % | Text | | `--o-radius-sm`, `--o-radius`, `--o-radius-lg` | `8px`, `14px`, `24px` | Small controls, cards, dialogs | | `--o-font-sans` | Noto Sans, then the system sans | Text | | `--o-font-display` | Barlow Condensed, Fira Sans Condensed, then a condensed system face | Numbers and headings | | `--o-duration` | `160ms` | Every transition | | `--o-bg`, `--o-bg-1` … `--o-bg-4` | | [Background](https://oyna-ui.org/components/background.md) | ## With Tailwind or UnoCSS The library needs neither, but if your project already uses one, the tokens are there as utilities, so the blocks you write yourself match the components. Every name starts with `o-`: nothing of your own theme is replaced. The values are the CSS variables above, so a theme you set with them reaches the utilities without a rebuild. **Tailwind 4.** One more import after Tailwind's own: ```css @import 'tailwindcss'; @import 'oyna-ui/tailwind.css'; ``` **UnoCSS.** A preset, next to `presetWind3` or `presetWind4`: ```ts import { defineConfig, presetWind4 } from 'unocss' import { presetOyna } from 'oyna-ui/unocss' export default defineConfig({ presets: [presetWind4(), presetOyna()] }) ``` Then, in either: ```vue

Deploys

Nothing yet.

``` | Utilities | From | | ---------------------------------------------------------- | ----------------------------------------------------- | | `*-o-accent`, `*-o-danger`, `*-o-on-accent` | The accent, the danger colour, text on an accent fill | | `*-o-surface`, `*-o-surface-strong`, `*-o-layer`, `*-o-bg` | Glass, darker glass, what floats, the page | | `*-o-fill-1` … `*-o-fill-4` | The white fills | | `*-o-text`, `*-o-text-2`, `*-o-text-3` | Text at 100, 75 and 50 % | | `rounded-o-sm`, `rounded-o`, `rounded-o-lg` | The three radii | | `font-o-sans`, `font-o-display` | The two typefaces | `*` is any utility that takes a colour: `bg-`, `text-`, `border-`, `ring-`, `fill-`. Tailwind also gets `duration-o` (the library's transition time, zero under reduced motion) and `backdrop-blur-o-layer`. ## Reduced motion When the user asks for reduced motion, `--o-duration` becomes `0ms` and every transition in the library stops. Use the variable in your own transitions to get the same behaviour. Source: https://oyna-ui.org/guide/theming --- # Hotkeys Hotkeys are read from the physical key (`KeyboardEvent.code`), not from the character it types. `KeyR` is the same key on a QWERTY, an AZERTY and a Russian layout. A hotkey is ignored: - while the user types in an `input`, a `textarea`, a `select` or an editable element. A checkbox, a radio and a switch are not typed into, so hotkeys work on them, except Space and the arrow keys, which are their own; - with Ctrl, Cmd or Alt held, so browser shortcuts keep working; - on key repeat, when the key is held down; - for `Enter` and `Space`, when a button or a link has focus: that element handles the key itself. The numpad Enter counts as `Enter`. While a [dialog](https://oyna-ui.org/components/dialog.md) or a [popover](https://oyna-ui.org/components/popover.md) is open, only the hotkeys set up inside it work. While a [select](https://oyna-ui.org/components/select.md) or a [dropdown menu](https://oyna-ui.org/components/dropdown-menu.md) is open, or a [key capture](https://oyna-ui.org/components/key-capture.md) waits for a key, none do. ## On a button The simplest way: [`OButton`](https://oyna-ui.org/components/button.md) and [`OToggle`](https://oyna-ui.org/components/toggle.md) take a `hotkey`, show it and react to it. ```vue Retry ``` ## useHotkey For anything that is not a button. The listener lives as long as the component. ```vue ``` | Argument | Type | Description | | ----------------- | --------------------------------------- | -------------------------------------------------------- | | `code` | `MaybeRefOrGetter` | Physical key, e.g. `KeyR`, `Digit1`, `Enter`, `Escape` | | `handler` | `(event: KeyboardEvent) => void` | Called on the key press; the default action is prevented | | `options.enabled` | `MaybeRefOrGetter` | `false` switches the hotkey off | ## hotkeyLabel `hotkeyLabel(code)` returns what to print for a key: `KeyR` → `R`, `Digit1` → `1`, `Escape` → `Esc`, `ArrowUp` → `↑`, `Slash` → `/`. The label names the key's position on a QWERTY keyboard. On another layout the same key may carry another letter; pass your own text where that matters. ## On a touch screen A phone has no keys to show. Where the main pointer is a finger (`pointer: coarse`), Button, Toggle and the dialog's close button hide their key; the hotkey itself stays registered, so a tablet with a keyboard attached still reacts to it. The same media query makes the library easier to hit: small buttons, the crosses of Alert and Tag, checkboxes, radios and switches get a target of about 44px without changing their shape, segmented tabs grow taller, and fields use 16px text, so iOS does not zoom the page on focus. Source: https://oyna-ui.org/guide/hotkeys --- # Icons Oyna UI ships no icons. Put any SVG in a component's slot and it takes the size of the text next to it. The examples on this site use [Lucide](https://lucide.dev): restrained line icons that match the look. ```bash [npm] npm install @lucide/vue ``` ```bash [pnpm] pnpm add @lucide/vue ``` ```bash [yarn] yarn add @lucide/vue ``` ```bash [bun] bun add @lucide/vue ``` ```vue ``` ## Where icons are sized for you | Component | How | | -------------------------------------------------------------------------------------- | -------------------------------------------------- | | [Button](https://oyna-ui.org/components/button.md), [Toggle](https://oyna-ui.org/components/toggle.md), [Badge](https://oyna-ui.org/components/badge.md) | An `` in the slot | | [DropdownMenu](https://oyna-ui.org/components/dropdown-menu.md) | `icon` of an item: the component itself, not a tag | | [Empty](https://oyna-ui.org/components/empty.md) | The `icon` slot | Anywhere else an icon is just an element in your own layout. ## Rules of the look - **Line icons, not filled ones, and no emoji.** They take the colour of the text, so they follow the tone of the component. - **An icon alone needs a name.** Give an icon-only button an `aria-label`; a [tooltip](https://oyna-ui.org/components/tooltip.md) on top helps sighted users. - **An icon next to a word is decoration.** Lucide marks its icons `aria-hidden` already, so screen readers read only the word. ```vue ``` ## Other icon sets Anything that renders an `` works the same way: another icon package, or an SVG you paste in. Icon fonts and CSS-mask icons (such as UnoCSS `i-*` classes) are not sized by the components; set their size yourself. Source: https://oyna-ui.org/guide/icons --- # Background The stage behind the glass: colour glows, a vignette that keeps the centre readable, and noise. Generated in CSS, with no image files. You are looking at it: this site uses the default background. ```vue ``` It is fixed to the viewport and sits behind the page (`z-index: -1`), so place it anywhere in your app. ## Colours Four glows over a base colour, each a CSS variable: ```css :root { --o-bg: #0b0b0f; --o-bg-1: #3a4fd0; /* top left */ --o-bg-2: #8a2bb8; /* top right */ --o-bg-3: #0e7f78; /* bottom right */ --o-bg-4: #b0561a; /* bottom left */ } ``` ## Your own background `OBackground` is optional. Any rich background works — a photo, a video, a gradient of your own — as long as it is dark enough for white text. Over a very busy one, use the `strong` [surface](https://oyna-ui.org/components/surface.md). Source: https://oyna-ui.org/components/background --- # Surface Glass: a translucent dark fill over the background. No borders. [`OCard`](https://oyna-ui.org/components/card.md) is a surface with padding, the darker fill, and an optional header and footer. ```vue Surface Strong surface Card ``` `OSurface` has no padding of its own: it is the fill and the radius, for layouts you size yourself. ## Signal A ring appears only when it means something. `accent` marks the main thing on the screen, or what belongs to the user. `danger` marks something at stake. Never use a ring as decoration: if everything has one, none of them says anything. ```vue Your plan Quota runs out ``` ## Props ### OSurface | Prop | Type | Default | Description | | -------- | ---------------------- | ------- | ------------------------------------------------------------ | | `as` | `string` | `'div'` | Element to render | | `strong` | `boolean` | `false` | A darker fill, for text-heavy content over a busy background | | `signal` | `'accent' \| 'danger'` | — | A ring that means something | For `OCard`, see [Card](https://oyna-ui.org/components/card.md). Source: https://oyna-ui.org/components/surface --- # Card The block most screens are made of: a darker [surface](https://oyna-ui.org/components/surface.md) with padding, and an optional header and footer. ```vue 131.6k ``` Everything but the content is optional. A card with no header and no footer is just padding around what you put in it. ## Header - `label` — a small caption: what the card is about. The quiet way to name a card, and the one to reach for first. - `title` — a heading in display type, for a card that is a section of the page. It is an `

`; set `title-as` to the level that fits your page. - `actions` — the slot at the right end: a badge, a button, a menu. ```vue Only a label and an action. Only a title. ``` ## Footer What comes after the content: a total, a note, a link to more. A thin line sets it apart — a line between two parts of one card, not a border around it. ## Signal The ring from [Surface](https://oyna-ui.org/components/surface.md#signal): `accent` for the main thing, `danger` for something at stake. One per screen, rarely two. ```vue v1.4.1 3 days ``` ## Props and slots | Prop | Type | Default | Description | | --------- | ---------------------- | ------- | ----------------------------- | | `label` | `string` | — | A small caption over the card | | `title` | `string` | — | A heading in display type | | `titleAs` | `string` | `'h3'` | The element of the title | | `signal` | `'accent' \| 'danger'` | — | A ring that means something | | `as` | `string` | `'div'` | Element to render | | Slot | Description | | --------- | ------------------------------- | | default | The content | | `actions` | At the right end of the header | | `footer` | Under the content, after a line | Source: https://oyna-ui.org/components/card --- # Button An action. One accent button per screen is the main thing; the rest stay quiet. A button can display its hotkey and own it. ```vue Save changes Cancel ``` ## Variants Five levels of weight. Use `primary` once per screen. ```vue Primary Secondary Soft Ghost Link → ``` ## Sizes and shapes Three sizes, a pill shape for navigation, and an icon-only button. Give an icon-only button an `aria-label`. ```vue Small Medium Large Pill ? ``` ## Link and current page With `href` the button renders a link. `aria-current` marks the current page in a navigation, as in the header of this site. ```vue Guide Components ``` ## Hotkey Pass a physical key code: the button shows the key and reacts to it on any keyboard layout. See [Hotkeys](https://oyna-ui.org/guide/hotkeys.md) for when a key is ignored. ```vue Retry Close Custom label ``` ## Loading Work is under way: the button shows a [spinner](https://oyna-ui.org/components/spinner.md), stays lit, and reacts neither to clicks nor to its hotkey. ```vue Deploy ``` ## Icons An `` in the slot takes the size of the text. See [Icons](https://oyna-ui.org/guide/icons.md). ## Disabled A disabled button does not react to its hotkey either. ## Props | Prop | Type | Default | Description | | ------------- | --------------------------------------------------------- | ------------- | --------------------------------------------------------------------------- | | `variant` | `'primary' \| 'secondary' \| 'soft' \| 'ghost' \| 'link'` | `'secondary'` | Visual weight | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | | | `shape` | `'rounded' \| 'pill'` | `'rounded'` | Pill for navigation and chips | | `icon` | `boolean` | `false` | A square (or round, with `pill`) button holding only an icon | | `hotkey` | `string` | — | Physical key (`KeyboardEvent.code`) that clicks the button; shown inside it | | `hotkeyLabel` | `string` | from `hotkey` | Text shown for the key | | `disabled` | `boolean` | `false` | Also turns the hotkey off | | `loading` | `boolean` | `false` | Shows a spinner; no clicks, no hotkey | | `href` | `string` | — | Renders a link instead of a button | | `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | Set `submit` inside a form | Source: https://oyna-ui.org/components/button --- # Kbd A key. It looks like a key cap: a face lit from above, standing on a dark lip. ```vue

Press Esc to close, ⌘ K to search.

Q W E Shift ``` - `key`, the default — a small cap inside a sentence. It is sized by the text around it. - `cap` — a large cap on its own, in display type: a list of bindings, a tutorial. - `outline` — a flat key for inside a control, where a cap has no room. It takes the colour of the text around it. ## Lights up when pressed Give a key its physical `code` and it lights up for as long as that key is held. Try `Q`, `W`, `E` or `Space` right here: ```vue Q W E Space ``` The cap does not sink or move: the library answers with light. The `code` is the key's position on the keyboard (`KeyboardEvent.code`), as for a [hotkey](https://oyna-ui.org/guide/hotkeys.md), so it works on any layout. Without a `code`, a key is just a picture of a key. This makes a hint under a screen confirm itself: the user presses the key and sees it was the right one. ## In a control ```vue M ``` You rarely need it yourself: a [button](https://oyna-ui.org/components/button.md#hotkey) and a [toggle](https://oyna-ui.org/components/toggle.md) draw the key of their own hotkey. ## Props | Prop | Type | Default | Description | | --------- | ----------------------------- | ------- | ----------------------------------------------------------------------- | | `variant` | `'key' \| 'cap' \| 'outline'` | `'key'` | | | `code` | `string` | — | Physical key (`KeyboardEvent.code`); the cap lights up while it is held | Source: https://oyna-ui.org/components/kbd --- # Badge A short label next to something: a plan, a status, a count. ```vue Pro New Expired ``` Put an [icon](https://oyna-ui.org/guide/icons.md) before the text: the badge sizes it and sets the gap. For something the user applied and can remove, use a [tag](https://oyna-ui.org/components/tag.md). ## Props | Prop | Type | Default | Description | | ------ | ---------------------- | ------- | ----------- | | `tone` | `'accent' \| 'danger'` | — | | Source: https://oyna-ui.org/components/badge --- # Tag Something the user applied and can take off: a filter, a label, a recipient. ```vue production v1.4.1 failing main removable ``` ## Removable `removable` adds a remove button. It emits `remove`; taking the tag away is up to you. Give the button a name that says what goes: a screen reader hears only the button. ```vue {{ filter }} ``` ## Tag or badge They look alike on purpose and differ in one thing: who put it there. - **Tag** — the user did: a filter, a label. It can usually be removed. Squarer corners. - **[Badge](https://oyna-ui.org/components/badge.md)** — the system did: a status, a plan, a count. It cannot be removed. A pill. For a choice that is on or off, use a [toggle](https://oyna-ui.org/components/toggle.md). ## Props | Prop | Type | Default | Description | | ------------- | ---------------------- | ---------- | ------------------------------------------------ | | `tone` | `'accent' \| 'danger'` | — | | | `removable` | `boolean` | `false` | Shows a remove button that emits `remove` | | `removeLabel` | `string` | `'Remove'` | The name of the remove button for screen readers | Source: https://oyna-ui.org/components/tag --- # Avatar A person, as a round picture or as initials when there is no picture. For a member in a list, the author of a deploy, the account in a header. ```vue ``` The initials are the first letters of the first two words of `name`. The name is also what a screen reader says, so give the full one even when a picture is shown. ## With a picture Pass `src`. The initials show until the picture has loaded, and stay if it fails to load, so a broken address never leaves an empty circle. ```vue ``` ## Next to a name An avatar sits in a row with text; the row is yours to lay out. ```vue
Ada Lovelace ada@example.com
``` ## Props | Prop | Type | Default | Description | | ------ | ---------------------- | -------- | ---------------------------------------------- | | `name` | `string` | required | Read out by screen readers; gives the initials | | `src` | `string` | — | Address of the picture | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 24, 32 or 48 px | Built on [Reka UI](https://reka-ui.com) Avatar. Source: https://oyna-ui.org/components/avatar --- # Input A text field. Its edge is a quiet 1px line; the ring turns accent on focus and danger when the value is wrong. `OField` adds a label and a message under it. ```vue ``` Every attribute goes to the `` itself: `type`, `placeholder`, `maxlength`, `autocomplete`, `disabled`. ## Field A label, the control, and a hint. When there is an `error`, it replaces the hint and the input gets the danger ring. The label and the message are tied to the input for screen readers; you set no ids. Try typing something other than `admin`: ```vue ``` The message keeps its line when empty, so the layout does not jump when an error appears. ## With a button ```vue
Subscribe ``` Without an `OField`, give the input an `aria-label`. ## Props ### OInput | Prop | Type | Default | Description | | --------- | ------------------ | ------- | ----------------------------------------------------------------- | | `v-model` | `string \| number` | — | | | `invalid` | `boolean` | `false` | A danger ring. Inside an `OField` with an error it is set for you | ### OField | Prop | Type | Default | Description | | ------- | -------- | -------- | ----------------------------------------------- | | `label` | `string` | required | | | `hint` | `string` | — | Shown under the control | | `error` | `string` | — | Replaces the hint and marks the control invalid | Source: https://oyna-ui.org/components/input --- # Textarea Text of several lines. It looks and behaves like an [input](https://oyna-ui.org/components/input.md), and works inside an `OField` the same way. ```vue ``` It is three rows high and the user can drag it taller. Every attribute goes to the `