effortless-ui

The component library inside the binary.

16 components and the classes for buttons, inputs, cards, menus and tables. effortless new turns the library on with one line in app/theme.css, and no file is copied into the project. It looks like shadcn/ui's default theme until a token says otherwise.

app/theme.css7 lines
@import "effortless-ui";

@theme static {
  /* --color-brand: light-dark(#2563eb, #60a5fa); */
  /* --radius-field: 0.375rem; */
  /* --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; */
}
app/theme.css26 lines
@import "effortless-ui";

@theme static {
  --color-brand: light-dark(#ef4a2b, #ff6a4d);
  --color-on-brand: light-dark(#ffffff, #140c09);

  --color-page: light-dark(#ffffff, #14120f);
  --color-surface: light-dark(#ffffff, #1d1a16);
  --color-soft: light-dark(#f5f3ef, #26221d);
  --color-ink: light-dark(#17150f, #f4f1ec);
  --color-muted: light-dark(#6f6a62, #a8a199);
  --color-line: light-dark(#e7e4df, #38332d);
  --color-input: light-dark(#e7e4df, #38332d);

  --color-danger: light-dark(#c2392b, #ff7a6b);
  --color-success: light-dark(#1f9d55, #4fd18b);
  --color-waitlist: light-dark(#c98f00, #f0b93a);

  --font-sans: "Bricolage Grotesque", system-ui, -apple-system, "Segoe UI", sans-serif;

  --radius-field: 0.625rem;
  --radius-box: 0.875rem;
  --spacing-field: 2.5rem;

  --shadow-card: 0 0 0 1px var(--color-line), 0 12px 32px -12px light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.6));
}
app/theme.css21 lines
@import "effortless-ui";

@theme static {
  --color-brand: light-dark(#0d7a6f, #60a5fa);
  --color-on-brand: light-dark(#ffffff, #0a0a0a);

  --color-page: light-dark(#f9fafb, #030712);
  --color-surface: light-dark(#ffffff, #111827);
  --color-ink: light-dark(#111827, #f3f4f6);
  --color-muted: light-dark(#4b5563, #9ca3af);
  --color-line: light-dark(#d1d5db, #374151);
  --color-input: light-dark(#d1d5db, #374151);

  --color-danger: light-dark(#b91c1c, #f87171);
  --color-success: light-dark(#166534, #4ade80);

  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;

  --radius-field: 0.5rem;
  --radius-box: 0.5rem;
}
routes/_layout.x.html
/* No token changes. Every colour of the library is
   light-dark(light, dark) and follows the visitor's
   system setting. This attribute forces one: */
<html data-theme="dark">

New event

Draft

People

PersonStatus
AO
Amara Okafor
[email protected]
Going
KS
Kenji Sato
[email protected]
Waitlist
CM
Chloé Martin
[email protected]
Cancelled

Nobody is waiting

The waitlist opens when the last place is taken.

Reminders go out the day before, at 09:00.
Places taken 34 of 40

The board is the library itself, running on this page: open the calendar, pick a time, search the groups, press a menu. A theme changes only the tokens in its file.

Classes for one element. Components for several.

A page is written with the browser's own elements. The library adds the look as a class, and wires elements together where one is not enough.

A class on the element

btn, input, card, badge and the rest go on a native element, which keeps its type, disabled, form and aria-*.

<a class="btn" href="/new">New event</a>
<input class="input" name="email">

A tag with no file

A ui- tag is the library's component unless the project has a file of that name. ui-field writes the label, the hint and the error the schema returned.

<ui-field label="Email"
          error={errors?.email}>
  <input class="input" name="email">
</ui-field>

Eject to change one

effortless eject copies a component's source into app/components. The copy belongs to the project, and no release changes it.

$ effortless eject ui-combobox
wrote app/components/ui-combobox.x.html

Every part, running.

Each part below is the library's own file, running on this page. The markup under it is the same part in a page of an Effortless project, as effortless fmt left it.

Buttons

btn

A class on a <button> or an <a>. Five kinds, three sizes, and a square one for an icon.

All events
Markup
routes/buttons/buttons.x.html
<button class="btn">Publish event</button>
<button class="btn btn-secondary">Save draft</button>
<button class="btn btn-outline">Cancel</button>
<button class="btn btn-ghost">Skip</button>
<button class="btn btn-danger">Delete</button>
<a class="btn btn-link" href="/events">All events</a>
<button class="btn btn-sm"><icon name="plus" />Invite</button>
<button
  class="btn btn-outline btn-icon"
  aria-label="Search"
  data-tip
><icon name="search" /></button>

Fields

ui-fieldui-selectinput

ui-field writes the label, the hint and the error around one control. The error comes from the schema's rules.

Markup
routes/fields/fields.x.html
<ui-field label="Title" hint="Shown on the event's page and in the reminder.">
  <input class="input" name="title" value="Portfolio night">
</ui-field>
<ui-field label="Places" error={errors?.places}>
  <input class="input" name="places" value="0" inputmode="numeric">
</ui-field>
<ui-field label="Who can come">
  <ui-select>
    <select class="input" name="audience">
      <option>Anyone</option>
      <option>Members of the group</option>
    </select>
  </ui-select>
</ui-field>
<ui-field label="Venue">
  <input class="input" name="venue" placeholder="Name and address">
</ui-field>

Checkbox, radio, switch

checkboxradioswitch

Classes on the browser's own inputs. They post as a checkbox and a radio do.

Markup
routes/choices/choices.x.html
<label class="flex items-center gap-2">
  <input type="checkbox" class="checkbox" name="remind" checked>Remind me the day before
</label>
<label
  class="flex items-center gap-2"
><input type="radio" class="radio" name="seen_by" value="anyone" checked>Anyone</label>
<label
  class="flex items-center gap-2"
><input type="radio" class="radio" name="seen_by" value="members">Members</label>
<label class="flex items-center gap-2">
  <input type="checkbox" role="switch" class="switch" name="waitlist" checked>Waitlist when full
</label>

Combobox

ui-combobox

A select for a long list. Typing filters it and ignores accents: chloe finds Chloé. With multiple, each pick is a chip.

Markup
routes/combobox/combobox.x.html
<ui-field label="Organizer">
  <ui-combobox name="organizer_id" options={people} value="1" />
</ui-field>
<ui-field label="Speakers">
  <ui-combobox name="speaker_ids[]" options={people} value={speakers} multiple={true} />
</ui-field>

Date picker

ui-datepicker

A date, a range, or a date and a time. Month and weekday names follow the page's language.

Markup
routes/datepicker/datepicker.x.html
<ui-field label="Starts on">
  <ui-datepicker name="starts_on" value="2026-10-22" />
</ui-field>
<ui-field label="Festival">
  <ui-datepicker
    range={true}
    name="first_day"
    value="2026-10-22"
    endName="last_day"
    end="2026-10-25"
  />
</ui-field>
<div class="sm:col-span-2 max-w-xs">
  <ui-field label="Doors open">
    <ui-datepicker time={true} name="opens_at" value="2026-10-22T18:30:00Z" step={15} />
  </ui-field>
</div>

Time picker

ui-timepicker

Typed or picked. 9, 930, 9:30 and 2pm are all read as times.

Markup
routes/timepicker/timepicker.x.html
<ui-field label="Opens at">
  <ui-timepicker name="opens_at" value="18:30" step={15} />
</ui-field>
<ui-field label="Closes at">
  <ui-timepicker name="closes_at" value="22:00" ampm={true} />
</ui-field>

Upload

ui-upload

A file input with an area to drop on and a list of what was picked. A picture shows as itself.

Markup
routes/upload/upload.x.html
<ui-field label="Cover image" hint="A picture, up to 10 MB.">
  <ui-upload name="cover" accept="image/*" />
</ui-field>

Markdown editor

ui-markdownprose

Written as it will read, stored as Markdown. The prose class styles the same text on the page.

Markup
routes/markdown/markdown.x.html
<ui-field label="About the event">
  <ui-markdown name="about" value={about} label="About the event" />
</ui-field>

Rows

ui-rowsui-row-tools

A list in a form that can be added to, removed from and dragged into another order. It posts in the order shown.

Markup
routes/rows/rows.x.html
<div class="flex flex-col gap-2" data-rows="tickets">
  <div class="flex items-center gap-2" data-row @each={tickets as ticket, i}>
    <input type="hidden" name="tickets[{i}][id]" value={ticket.id}>
    <input class="input" name="tickets[{i}][name]" value={ticket.name}>
    <ui-row-tools label="Remove ticket" />
  </div>
  <template>
    <div class="flex items-center gap-2" data-row>
      <input class="input" name="tickets[new][name]" placeholder="Ticket name">
      <ui-row-tools label="Remove ticket" />
    </div>
  </template>
  <button
    type="button"
    class="btn btn-outline btn-sm self-start"
    data-add
  ><icon name="plus" />Add a ticket</button>
</div>

Dialog, drawer, confirm

ui-dialogui-confirmdialog

The browser's <dialog>, opened by a button with commandfor. No script. Escape and a press outside close it.

Invite a person

They get a link by mail.

Portfolio night

Thursday 22 October, 18:30

34 of 40 places are taken.

Cancel this event?

34 people get an email.

Markup
routes/dialog/dialog.x.html
<button class="btn" commandfor="invite" command="show-modal">Invite a person</button>
<ui-dialog id="invite" title="Invite a person" text="They get a link by mail.">
  <form method="POST" class="flex flex-col gap-4">
    <ui-field label="Email"><input class="input" name="email" type="email"></ui-field>
    <footer><button class="btn">Send invitation</button></footer>
  </form>
</ui-dialog>

<button
  class="btn btn-outline"
  commandfor="details"
  command="show-modal"
>Open as a drawer</button>
<ui-dialog id="details" title="Portfolio night" text="Thursday 22 October, 18:30" side={true}>
  <p class="text-sm text-muted">34 of 40 places are taken.</p>
</ui-dialog>

<button
  class="btn btn-danger"
  commandfor="cancel-event"
  command="show-modal"
>Cancel event</button>
<ui-confirm
  id="cancel-event"
  title="Cancel this event?"
  text="34 people get an email."
  label="Cancel event"
  cancel="Keep it"
  danger={true}
>
  <input type="hidden" name="id" value="7">
</ui-confirm>

Toast, tooltip, copy

ui-toasterui-tooltipui-copy

A form says data-toast="Saved" and the message shows once the write worked. An island calls toast(). data-tip is a tooltip.

Paid
Markup
routes/toast/toast.x.html
<toast-buttons />
<ui-copy text="https://commons.example/e/portfolio-night" label="Copy link" />
<button
  class="btn btn-ghost btn-icon"
  aria-label="Pin to the top"
  data-tip
><icon name="pin" /></button>
<span class="badge badge-success" data-tip="Paid on 14 October">Paid</span>
app/components/toast-buttons.x.html
<script>
  import { toast } from './toast.js'

  function saved() { toast('Event saved') }
  function failed() { toast('Could not reach the server', { kind: 'danger' }) }
  function archived() { toast('Event archived', { action: 'Undo', run: restored }) }
  function restored() { toast('Event restored', { kind: 'success' }) }
</script>

<div class="flex flex-wrap gap-2">
  <button class="btn btn-outline" type="button" @click={saved}>Save</button>
  <button class="btn btn-outline" type="button" @click={failed}>Fail</button>
  <button class="btn btn-outline" type="button" @click={archived}>Archive</button>
</div>

Tabs

tabstabbed

Links for tabs that are pages. Radios for tabs that switch what is on the page, with no request and no script.

34 people are going. Reminders go out the day before.
6 people are waiting. The first gets a place when someone cancels.
2 people cancelled.
Markup
routes/tabs/tabs.x.html
<div class="tabbed">
  <div class="tabs">
    <label><input type="radio" name="tab" value="going" checked>Going</label>
    <label><input type="radio" name="tab" value="waitlist">Waitlist</label>
    <label><input type="radio" name="tab" value="cancelled">Cancelled</label>
  </div>
  <div class="pt-4 text-sm">34 people are going. Reminders go out the day before.</div>
  <div
    class="pt-4 text-sm"
  >6 people are waiting. The first gets a place when someone cancels.</div>
  <div class="pt-4 text-sm">2 people cancelled.</div>
</div>

Table, badge, avatar, pager

tablebadgeavatarui-pager

A class on <table>, badges and avatars on <span>. A table wider than its box scrolls inside it. ui-pager draws the pages of what eql.page returned.

EventHostStartsPlaces
Portfolio night AAmara 22 Oct 6 left
Type crit KKenji 24 Oct Full
Figma file review CChloé 27 Oct 18 left
Product breakfast SSofia 29 Oct Full
Markup
routes/table/table.x.html
<div class="overflow-x-auto">
  <table class="table">
    <thead>
      <tr><th>Event</th><th>Host</th><th>Starts</th><th>Places</th></tr>
    </thead>
    <tbody>
      <tr @each={page.rows as event}>
        <td class="font-medium">{event.title}</td>
        <td>
          <span
            class="flex items-center gap-2"
          ><span class="avatar avatar-sm">{event.host.slice(0, 1)}</span>{event.host}</span>
        </td>
        <td>{event.starts_on.formatDate('d MMM')}</td>
        <td>
          <span class="badge badge-danger" @if={event.taken >= event.places}>Full</span>
          <span class="badge badge-success" @else>{event.places - event.taken} left</span>
        </td>
      </tr>
    </tbody>
  </table>
</div>
<ui-pager page={page} />

Alert, progress, skeleton, kbd

alertprogressspinnerskeletonkbd

What a page says while it works and when it is done.

Reminders sent to 34 people.
⌘ K
Markup
routes/status/status.x.html
<div
  class="alert alert-success"
  role="status"
><icon name="circle-check" />Reminders sent to 34 people.</div>
<div
  class="alert alert-danger"
  role="alert"
><icon name="octagon-x" />The cover image is larger than 10 MB.</div>
<progress class="progress" value="34" max="40"></progress>
<div class="flex items-center gap-3">
  <div class="skeleton size-8 rounded-full"></div>
  <div class="skeleton h-4 w-48"></div>
  <icon name="loader-circle" class="spinner" label="Loading" />
  <kbd class="kbd">⌘ K</kbd>
</div>

Empty state, accordion, card

emptyaccordioncard

An empty list says what goes there. An accordion is <details>, and the browser keeps one open at a time.

No events yet

An event shows here once a group posts one.

What does it cost?

Nothing. Groups pay for their own venue.

Can I bring a guest?

Each sign-up holds one place.

Markup
routes/empty/empty.x.html
<div class="empty empty-outline">
  <icon name="file" />
  <h3>No events yet</h3>
  <p>An event shows here once a group posts one.</p>
  <div><a class="btn" href="/events/new">New event</a></div>
</div>
<div class="card p-5">
  <div class="accordion">
    <details name="faq" open>
      <summary>What does it cost?</summary>
      <p>Nothing. Groups pay for their own venue.</p>
    </details>
    <details name="faq">
      <summary>Can I bring a guest?</summary>
      <p>Each sign-up holds one place.</p>
    </details>
  </div>
</div>

Built from the browser's own elements.

A part uses what the browser has before it gets code of its own. Six of the 16 components have no script, and none of the classes do.

Dialogs

A dialog, a drawer and a confirmation are the <dialog> element. A button opens one with commandfor. Escape closes it.

Menus and popovers

The popover attribute, placed against its button by CSS anchor positioning. focusgroup gives a menu its arrow keys.

Tabs and accordions

Tabs that switch what is on the page are radio inputs, so one form can spread over them. An accordion is <details>.

Where a script runs

The combobox, the date and time pickers, the upload list, the Markdown editor, sortable rows, toasts, tooltips and the copy button. One that holds a value posts it from an input the server writes, so a form sends it before the script has run.

  • Browsers

    Anchor positioning is in Chrome 133, Safari 26 and Firefox 147. In older ones /_inlay.js places the popovers, back to Chrome 114, Safari 17 and Firefox 125.

  • Releases

    Before 1.0 a release can change how a component looks or what it writes. The changelog says when, and an ejected component stays as it was.

  • Icons

    The binary holds the 37 Lucide icons its components use. effortless icon <name> writes any other into app/icons.

Your agent reads it from the binary.

The classes are Tailwind's and the look is shadcn/ui's, which coding agents already write. For the parts that are new, the reference is in the program the agent runs, at the version it runs.

zsh
$ effortless eject
  ui-combobox      A select for a long list: typing filters it.
  ui-confirm       A question before an action that cannot be taken back.
  ui-datepicker    A date, a range of dates, or a date and a time, picked from a calendar.
  ui-dialog        A dialog over the page, or a drawer at its edge.
  ui-field         A label, hint and error around one control.
  … 11 more

$ effortless docs ui-combobox
### ui-combobox

A select for a long list: typing filters it. A short list is a `<select>` in `ui-select`.
…

$ effortless check
routes/login/login.x.html:20: [class] "btn-dangr" styles nothing: it is not a Tailwind class,
and no CSS in public/ or a <style> has .btn-dangr

Acknowledgements

effortless-ui is heavily inspired by shadcn/ui. Its default theme is shadcn/ui's, and the sizes, colours and states of each part follow shadcn/ui's components. The parts are built another way, as classes on the browser's own elements and Inlay components, so nothing here needs React.

The icons are Lucide. The utility classes are Tailwind CSS.