npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lyeve-labs/ui-kit

v0.60.1

Published

A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.

Readme

@lyeve-labs/ui-kit

A component library for Svelte 5 and Tailwind CSS 4, with a dark and a light theme drawn from one stylesheet of design tokens. It is the design system the LyEve applications are built from.

License: MIT Svelte 5 Tailwind CSS 4

pnpm add @lyeve-labs/ui-kit @lucide/svelte
<script lang="ts">
  import { Button, Card, toast } from '@lyeve-labs/ui-kit';
  import '@lyeve-labs/ui-kit/styles.css';
</script>

<Card title="Welcome">
  <p>Your account is ready.</p>
  <Button onclick={() => toast.success('Invitation sent.')}>Invite teammate</Button>
</Card>

Importing the stylesheet is the only setup. There is no config file and no theme provider.

What the package holds

  • Components for layout, forms, navigation, overlays and feedback, listed below.
  • Two themes: Soft Dark, the default, and Soft Light, switched by the data-theme attribute on <html>.
  • One stylesheet: @lyeve-labs/ui-kit/styles.css declares every token, and the components style themselves with Tailwind utilities that read them.
  • Svelte 5 runes and snippets, with every prop typed.
  • Native elements: a <Button /> renders a <button> and an <Input /> an <input>.

Component list

Layout and structure Card, Panel, AppShell, AuthShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, DiffView, Toolbar, TreeView, SortableList, SplitPane

Forms and inputs Button, ButtonGroup, Input, PasswordInput, CopyField, Textarea, NumberInput, SearchInput, FileInput, ColorField, Label, Field, FormMessage, FormAssist, SegmentedControl, FilterChips, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle

Navigation Breadcrumb, Tabs, Pagination, StepIndicator, Dropdown, SidebarNav, AccountMenu, AccountMenuItem

Overlays Modal, Drawer, Tooltip

Dialogs Dialog, DialogContainer, ConfirmDialog (plus the openDialog and confirm services)

Feedback and status Alert, Banner, RefusalNotice, GateNotice, Badge, Tag, Indicator, Progress, Spinner, Skeleton, CheckoutSteps, EmptyState, PluginCart, Stat, Kbd, CopyButton

Media Avatar, AvatarGroup, Logo, Image, Gallery

Pages for visitors Section, Columns, Prose, Quote, NavBar, Footer

Theming ThemeToggle, Toaster (plus the toast service)

Requirements

  • Svelte 5.0 or newer
  • Tailwind CSS v4 with the @tailwindcss/vite plugin
  • @lucide/svelte 0.511 or newer, below 2.0
  • Node 24 or newer

Install

pnpm add @lyeve-labs/ui-kit @lucide/svelte
# or npm install @lyeve-labs/ui-kit @lucide/svelte
# or yarn add @lyeve-labs/ui-kit @lucide/svelte

The icon set is a peer dependency, so it installs once and the application picks the version. Most applications already depend on it directly, and while the kit owned a copy of its own they resolved two: the same icons shipped twice in the bundle and an upgrade on the application's side moved only one of them. A missing peer is resolved for you by pnpm and by npm, and not by yarn.

If you don't have Tailwind v4 yet:

pnpm add -D tailwindcss @tailwindcss/vite
// vite.config.ts
import { sveltekit } from '@sveltejs/vite-plugin-svelte';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [tailwindcss(), sveltekit()],
});

Use

Import the stylesheet once at the top of your app's CSS entry:

/* src/app.css */
@import '@lyeve-labs/ui-kit/styles.css';

Then use the components in your routes:

<script lang="ts">
  import { Button, Card, Toaster, toast } from '@lyeve-labs/ui-kit';
</script>

<Card title="Profile" description="Update your details.">
  <Button onclick={() => toast.success('Saved.')}>Save</Button>
</Card>

<Toaster />

Mount one <Toaster /> in your root layout. It takes no props and draws the toasts the toast service queues:

| Call | Does | | ---------------------------------- | ----------------------------------------- | | toast.success(message, timeout?) | queues a success toast and returns its id | | toast.info(message, timeout?) | the same in the brand tone | | toast.warn(message, timeout?) | the same in the warning tone | | toast.error(message, timeout?) | the same in the danger tone | | toast.dismiss(id) | removes a toast before its timeout |

timeout is in milliseconds and defaults to 4000. A timeout of 0 keeps the toast until it is dismissed.

Theming

The kit ships two themes, and one attribute switches between them:

<html data-theme="light">
  <!-- light mode -->
</html>
<html>
  <!-- dark mode (default) -->
</html>
<html data-theme="system">
  <!-- light or dark, as the operating system asks, with no script -->
</html>

Or use the helpers, which also store the reader's choice:

import { setThemePreference, getTheme } from '@lyeve-labs/ui-kit';

setThemePreference('light'); // 'dark', 'light' or 'system', returns the theme applied
getTheme(); // 'dark' | 'light'

The helpers write light or dark to the attribute and resolve the system preference when they run. Only markup names system, for a page that runs no script.

To keep the first paint in the reader's theme, inline the output of themeBootScript() from @lyeve-labs/ui-kit in the <head> of your app.html, before any stylesheet.

To match your own brand, override individual tokens after the import, once for each theme. The @theme block sets the dark palette. The light palette is its own rule, and yours outranks the kit's because it comes after the import. A token you leave out keeps the kit's value, and a page printed under these rules stays black on white.

@import '@lyeve-labs/ui-kit/styles.css';

@theme {
  --color-brand: #ff5e9c;
  --color-brand-light: #ff85b3;
  --color-brand-deep: #f0508f;
}

html[data-theme='light'] {
  --color-brand: #b51758;
  --color-brand-light: #9f144d;
  --color-brand-deep: #8b1244;
}

Focus rings, the focus outline and a focused field's border draw in brand-deep, through the focus color (ring-focus, outline-focus, border-focus). Set all three brand tokens, or the rings keep the kit's color beside your brand. The color is read where each control sits, so a wrapper that sets its own brand tokens recolors the rings inside it too.

A page that names system takes its light palette from a copy of that rule inside @media (prefers-color-scheme: light), so its light overrides go there too:

@media (prefers-color-scheme: light) {
  html[data-theme='system'] {
    --color-brand: #b51758;
    --color-brand-light: #9f144d;
    --color-brand-deep: #8b1244;
  }
}

Colors interact. In the light theme a filled button's label is near white, and the primary button lays a faint white lift over its fill, so a bright hover color fails there: #e91e63 as the light brand-light leaves the label at 3.77:1. checkPalette finds pairs like that. It runs the contrast rules the kit's own palettes pass over your colors and returns each pair below its WCAG 2.2 floor with the theme, where it is painted, the two tokens, the ratio and the floor. An empty list means the palette passes:

import { checkPalette } from '@lyeve-labs/ui-kit';

expect(
  checkPalette({
    dark: { brand: '#ff5e9c', 'brand-light': '#ff85b3', 'brand-deep': '#f0508f' },
    light: { brand: '#b51758', 'brand-light': '#9f144d', 'brand-deep': '#8b1244' },
  }),
).toEqual([]);

It reads #rgb and #rrggbb, and it throws on any other format, on a token it does not check and on a theme other than dark and light, so a typo cannot pass.

A page without a Tailwind build

styles.css is Tailwind source, so a page with no Tailwind build of its own gets no class rules from it. site.css is the same stylesheet compiled for the components a site's blocks draw with: Accordion, AccordionItem, Badge, Button, Card, Checkbox, Columns, Field, Footer, FormMessage, Gallery, Image, Input, Label, NavBar, Prose, Quote, Section, SectionHeading and Textarea. It carries the dark default, the light palette and system, so the page names its scheme on <html> as above.

import '@lyeve-labs/ui-kit/site.css';

A static page can link the file from dist/site.css instead. Any other component needs the Tailwind build.

A site sets its own look on the root, after the stylesheet, and the components follow. Each value has a default, so a site sets only what it changes:

:root {
  --radius-md: 4px; /* every kit rung is a multiple of it: 0 squares the page */
  --font-heading: 'Source Serif 4', serif;
  --spacing-site-md: 1.5rem; /* xs, sm, lg and xl follow at 1/4, 1/2, 2 and 4 times */
  --spacing-column-min: 280px; /* columns stack below this width */
  --spacing-gutter: 24px; /* the gap between columns */
  --color-brand-2: #b45309; /* a second color, once for each scheme the page draws */
}

Set --radius-md and --spacing-site-md on :root itself. The other rungs are worked out there, so a value set on an element inside the page reaches only the components that read md directly.

A part of a page can draw in its own scheme, whatever the page's. A Section with an overlay over its image does this, and any element can:

<div data-scheme="dark">
  <!-- the dark palette inside, on a light or a dark page -->
</div>

A site with its own palette writes its dark colors into a [data-scheme='dark'] rule as well, the same way a light palette is written twice for system, and its light colors into [data-scheme='light']. That includes --color-brand-2: a part works out its second color from its own palette, so a value set on :root alone stops at the part.

A page put together from several server renders, such as one for each block, passes each render its own idPrefix. Ids the components make count from the start of each render, so without it two renders repeat them: a label points at another block's field, and two accordions share a name, where opening an item in one closes an item in the other.

Motion

Every component moves on the same four durations and three curves, declared in styles.css beside the palette:

| Token | Value | For | | --------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------- | | --duration-fast | 120ms | a state change in place: color, opacity, border, shadow, focus | | --duration-base | 200ms | movement in place (a knob, a chevron, a collapse) and a small surface arriving (menu, popover, tooltip, toast) | | --duration-slow | 320ms | a large surface arriving (dialog, drawer) | | --duration-progress | 500ms | a value moving (a progress bar's width) | | --ease-enter | cubic-bezier(0.22, 1, 0.36, 1) | arriving: starts fast and settles | | --ease-exit | cubic-bezier(0.32, 0, 0.67, 0) | leaving: starts slow and accelerates away | | --ease-move | cubic-bezier(0.65, 0, 0.35, 1) | changing in place |

An exit runs one rung faster than its entrance, on the exit curve. The transition-* utilities default to the fast rung and the move curve, so transition-colors on its own is complete. duration-base, duration-slow, duration-progress, ease-enter, ease-exit and ease-move are utilities for the rest. A surface of your own that mounts and unmounts enters and leaves through the same presets the kit's overlays use:

<script>
  import { motion } from '@lyeve-labs/ui-kit';
</script>

{#if open}
  <div transition:motion.popover|global>...</div>
{/if}

{#each items as item (item.id)}
  <li transition:motion.toast|global animate:motion.reorder>...</li>
{/each}

dialog, scrim, drawer, popover and toast read the tokens at run time, so retuning a token retunes them, and every one of them plays nothing for a reader who has asked for reduced motion. The |global modifier is what lets the exit play when a parent block removes the surface, so keep it. Do not write duration-150, ease-out or transition-all beside a kit class: they are a fifth speed and a fourth curve, and the kit's own test suite refuses them.

A page is a parent block too. When the router replaces it, every exit on it plays, and the page stays on screen with the new page below it until the last one ends. Call motion.swapPage() just before the router swaps the page, and the presets play in no time until the next frame, so the new page takes the old one's place at once:

<script>
  import { onNavigate } from '$app/navigation';
  import { motion } from '@lyeve-labs/ui-kit';

  onNavigate((navigation) => {
    if (navigation.from?.url.pathname !== navigation.to?.url.pathname) motion.swapPage();
  });
</script>

Sizing

Two ladders, both declared as tokens and both read by name.

A page picks a role and PageShell picks the cap:

| width | Cap | For | | --------- | ------ | ------------------------------------------ | | narrow | 896px | one column: a form, a settings pane | | default | 1152px | a page with nothing dense on it | | wide | 1536px | a page holding a table or other dense data | | full | none | a canvas or a split pane |

A surface lifted off the page (Modal, Drawer, a dialog) takes a rung of one shared ladder, so the same form is the same size whichever of the three a page opens it in: sm 448px, md 576px, lg 704px, xl 896px, and full 1088px for a dialog holding a table.

Modal and Drawer default to size="auto" and take the rung their body earns: md up to four fields, lg past four, xl past eight. The count is the fields the panel actually rendered, re-read when the form reveals more, and a radio or checkbox group counts as the one question it asks. Name a rung and it is kept.

Component notes

What a component does beyond its props table.

A filter kept in the URL

SegmentedControl and FilterChips take an href on each option. When every option carries one, the options render as plain links: the current one carries aria-current="page", no change is reported, and the row works before the page hydrates. Every option carries an href or none does, and a row that mixes the two throws.

<FilterChips
  label="Status"
  value={status}
  options={[
    { value: 'all', label: 'All', href: '?status=all' },
    { value: 'open', label: 'Open', count: 7, href: '?status=open' },
  ]}
/>

Hints and errors on a choice row

SegmentedControl and FilterChips take id, hint, error and describedBy, the field props Input, Select and RadioGroup take. The hint shows under the row, and an error replaces it. The group points aria-describedby at whichever is on screen, then at the ids in describedBy, so a row inside a Field reads that field's message too. A segmented row of radios carries aria-invalid while it shows an error. ARIA does not allow that attribute on a group, so a row of chips or links reports its error through the description alone. Both link modes take the same props.

SegmentedControl draws its own caption from label. Inside a Field, which draws the label already, pass labelHidden so the name shows once and the group keeps it for a screen reader. FilterChips draws no caption, and its label only names the group.

<SegmentedControl
  label="Billing period"
  options={periods}
  bind:value={period}
  hint="Charged at the start of each period."
  error={form?.errors?.period}
/>

Empty states

EmptyState renders its title as an h3, for a list inside a section. On a page whose only other heading is its h1, pass level={2} so the outline does not skip a level. The title looks the same at either level.

Copying

CopyButton takes text for words beside its icon, such as "Copy all codes", and the words then name the button. title adds a tooltip, shown on hover and on keyboard focus. It never renames the button, and when it says more than the name it becomes the button's description. CopyField shows its value on one truncated line, and wrap shows the whole value across as many lines as it takes, for a key the reader has to check end to end. It breaks after a hyphen where it can, and inside a token only when the token is wider than the line.

A control the kit does not ship copies through the same write and confirms on the same clock with CopyState. Create one while the component initializes, read status in markup, and dispose of it when the component unmounts.

<script lang="ts">
  import { Button, CopyState } from '@lyeve-labs/ui-kit';

  let { id }: { id: string } = $props();
  const copy = new CopyState();
  $effect(() => () => copy.dispose());
</script>

<Button variant="ghost" size="sm" onclick={() => copy.copy(id)}>
  {copy.status === 'copied' ? 'Copied' : 'Copy ID'}
</Button>

copyToClipboard(text) is the write alone. It resolves to whether the copy worked and never throws. Where the async clipboard is missing, as it is on an origin without TLS, it falls back to the legacy copy command.

Page titles

PageShell renders the document title once an app states its format, in its root layout. A page then drops its own title element, which would otherwise race the shell's. documentTitle gives the tab other words than the heading. A format that reads reactive state, such as a tenant's name, follows it. An app that states no format gets no title element from the kit.

<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { setDocumentTitleFormat } from '@lyeve-labs/ui-kit';

  let { children } = $props();
  setDocumentTitleFormat((title) => `${title} | Acme`);
</script>

{@render children()}
<PageShell title="Edit entry" documentTitle={entry.name}>...</PageShell>

A sign-in page in a tenant's colors

AuthShell takes a lockup snippet that replaces the product mark above the heading, and passes class and style to its outer element. Custom properties set there scope to the page, so a tenant's accent repaints the brand tokens for everything on it. Inside the href link the lockup names the link, so give it words or an image with alt text.

<AuthShell title="Sign in" href="/" style="--color-brand: {tenant.accent}">
  {#snippet lockup()}
    <span class="inline-flex items-center gap-2 text-xl font-semibold text-fg">
      <img src={tenant.logo} alt="" class="size-9" />
      {tenant.name}
    </span>
  {/snippet}
  <form method="POST">...</form>
</AuthShell>

Tone names

StatusTone is the tone Alert, Banner and Stat take. AccentTone adds violet and is the tone Badge, Tag, Indicator and Progress take. An app that maps its own states onto tones types the map with them.

import type { StatusTone } from '@lyeve-labs/ui-kit';

const JOB_TONE: Record<JobState, StatusTone> = {
  queued: 'neutral',
  running: 'brand',
  failed: 'danger',
  done: 'success',
};

Local development

The kit is developed on Node 26 and installs with pnpm only. Applications that consume it need Node 24 or newer and may use any package manager.

pnpm install            # install dependencies
pnpm test               # run unit tests
pnpm check              # type-check and svelte-check
pnpm build              # svelte-package and publint, into dist/

Project layout

src/
└── lib/                 # published as @lyeve-labs/ui-kit
    ├── components/      # the components
    ├── internal/        # shared pieces the package does not export
    ├── stores/          # the toast queue
    ├── styles/          # theme.css, the one stylesheet
    ├── utils/           # theme, palette and class helpers
    └── index.ts         # the public API

Versioning

@lyeve-labs/ui-kit follows SemVer. While under 1.0, breaking changes bump the minor version. Additive changes bump the patch. Every release is logged in CHANGELOG.md.

Maintainers: the release workflow is CONTRIBUTING.md.

Contributing

Bug reports, prop suggestions and component proposals are welcome. See CONTRIBUTING.md for the development setup and the coding conventions.

License

MIT. See LICENSE.