Button
buttonuiInteractive button component with variants (default, destructive, outline, secondary, ghost, link), sizes, and composite ButtonGroup container for segmented toolbars and split buttons.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/button.json$npx shadcn@latest add https://uipkge.dev/r/lit/button.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/button.json$bunx shadcn@latest add https://uipkge.dev/r/lit/button.jsonManifest:
https://uipkge.dev/r/lit/button.jsonInstalls to:src/uip/components/button/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | 'default''destructive''outline''secondary''ghost''link' | default | optional |
size | 'default''sm''lg''xs''icon''icon-sm''icon-lg' | default | optional |
type | 'button' | 'submit' | 'reset' | button | optional |
name | string | — | optional |
value | string | — | optional |
formaction | string | — | optional |
formmethod | string | — | optional |
formenctype | string | — | optional |
formnovalidate | boolean | false | optional |
formtarget | string | — | optional |
disabled | boolean | false | optional |
href | string | — | optional |
aria-label | string | — | optional |
aria-expanded | string | — | optional |
aria-haspopup | string | — | optional |
aria-description | string | — | optional |
aria-controls | string | — | optional |
target | string | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-button': UipButton
}
}npm dependencies
Includes
Used by
auth-mfaauth-password-resetauth-sign-inauth-sign-upbento-01calendarcareers-job-boardcart-drawerchangelogchat-threadchat-two-panecheckout-flowcoming-sooncontact-01contact-uscookie-consent-barcta-01cta-final-bandcustomer-reviewsdashboard-layoutdata-table-pageempty-statesenterprise-workspace-switchererror-404error-500error-pagesevent-calendar-dashboardfaq-01faq-two-columnfeature-alternating-rowsfeatures-01flash-sale-offer-barfooterfooter-01form-layoutsfrequently-bought-togetherganttheaderheader-01header-mega-menuherohero-01inboxkanban-task-boardleaflet-maplogin-01login-02logo-cloud-grayscalelogosnewsletternotification-centernotifications-popoveronboarding-checklistonboarding-wizardorder-confirmationorder-management-pagepayment-formpricingpricing-01pricing-matrix-plan-cardsproduct-detail-pageproduct-grid-pageproduct-qa-communityproject-roadmaprange-calendarrecord-detail-pageregister-01rich-text-editorsearch-results-pagesettings-pageshortcuts-dialogstats-bandteam-sectiontechnical-specs-sheettestimonialtheme-customizetime-pickertouruser-profile-pagewaitlist
Files installed (3)
src/uip/components/button/button.ts10.7 kB
import { LitElement, css, html, nothing } from 'lit' import { cn } from '../../lib/utils' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { buttonVariants, type ButtonVariants } from './button.variants' type Variant = NonNullable<ButtonVariants['variant']> type Size = NonNullable<ButtonVariants['size']> /** * <uip-button> — the registry Button as a web component. * * Class strings are React's `buttonVariants` verbatim. Two shadow-DOM * adaptations (a light-DOM <svg> is not a descendant of the inner <button>): * - icon sizing moves from `[&_svg]:…` to `::slotted(svg)` on the <slot>; * - React's `has-[>svg]:px-*` can't see slotted icons, so the element sets * `data-icon` on the inner button when a slotted <svg> is present and the * size padding is re-expressed as `data-[icon]:px-*`. * `href` renders an <a> (the web-component stand-in for React's `asChild`). * * Inside a <uip-button-group> the group sets `data-group-position` * (first | middle | last | only) and `data-group-orientation` on the host; the * button then applies React ButtonGroup's child rules to its inner element * (rounding, `-ml-px` / `-mt-px`, divider border per variant, hover/focus z-index). * * Form-associated (like React's <button>, which extends ButtonHTMLAttributes): * `type` (`button` | `submit` | `reset`) drives the ancestor form — `submit` * calls `requestSubmit()`, `reset` calls `reset()`. A shadow-DOM <button> can't * submit the host's form on its own, so the click is forwarded through * ElementInternals. `name` + `value` are submitted as name=value only on the * submission this button triggers (set just before requestSubmit, cleared * after). `formaction` / `formmethod` / `formenctype` / `formnovalidate` / * `formtarget` (React's formAction …) temporarily override the form's * attributes for that submission. The native `form` attribute (form id) works * as-is via form association — no property needed. */ const activeFormSubmissions = new WeakMap<HTMLFormElement, { count: number; saved: Map<string, string | null> }>() export class UipButton extends LitElement { static formAssociated = true static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: inline-flex; }`] static properties = { variant: { reflect: true }, size: { reflect: true }, type: { reflect: true }, name: { reflect: true }, value: {}, formAction: { attribute: 'formaction' }, formMethod: { attribute: 'formmethod' }, formEnctype: { attribute: 'formenctype' }, formNoValidate: { type: Boolean, attribute: 'formnovalidate' }, formTarget: { attribute: 'formtarget' }, disabled: { type: Boolean, reflect: true }, href: {}, // The focusable element is the inner <button>, so the host's aria-label // must be forwarded or icon-only buttons have no accessible name. accessibleLabel: { attribute: 'aria-label' }, // Popup-trigger state must reach the inner <button> too, or assistive tech // never hears it when a uip-button is a menu / popover / collapsible trigger. fwdExpanded: { attribute: 'aria-expanded' }, fwdHaspopup: { attribute: 'aria-haspopup' }, fwdDescription: { attribute: 'aria-description' }, fwdControls: { attribute: 'aria-controls' }, target: {}, groupPosition: { attribute: 'data-group-position' }, groupOrientation: { attribute: 'data-group-orientation' }, hasIcon: { state: true }, } variant: Variant = 'default' size: Size = 'default' type: 'button' | 'submit' | 'reset' = 'button' name?: string value?: string formAction?: string formMethod?: string formEnctype?: string formNoValidate = false formTarget?: string disabled = false href?: string accessibleLabel?: string fwdExpanded?: string fwdHaspopup?: string fwdDescription?: string fwdControls?: string target?: string groupPosition?: 'first' | 'middle' | 'last' | 'only' groupOrientation?: 'horizontal' | 'vertical' private hasIcon = false private internals = this.attachInternals() constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'button') } willUpdate() { this.setAttribute('data-variant', this.variant) this.setAttribute('data-size', this.size) } // aria-controls is an id in the host's tree; an id can't be referenced from // inside the shadow root, so point the inner control at the element itself. protected updated() { const inner = this.renderRoot.querySelector<HTMLElement>('[part=base]') if (!inner) return const root = this.getRootNode() as Document | ShadowRoot const target = this.fwdControls ? root.getElementById?.(this.fwdControls) : null inner.ariaControlsElements = target ? [target] : null } private onSlotChange(e: Event) { const slot = e.target as HTMLSlotElement this.hasIcon = slot.assignedElements().some((el) => el.localName === 'svg') } formDisabledCallback(disabled: boolean) { this.disabled = disabled } // A <button> inside a shadow root can't submit the host's form on its own, // so submit/reset are forwarded through ElementInternals. The inner button // keeps the real `type` for semantics/inspection; the outer form action // happens here. private onClick() { if (this.disabled || this.href) return const form = this.internals.form if (!form) return if (this.type === 'reset') { form.reset() return } if (this.type !== 'submit') return // Native submit buttons send name=value only when they are the submitter. // Emulate that: publish the value just for this submission, then clear it // once the form data has been captured (formdata) or on the next tick as // a fallback (submit prevented / no data). if (this.name != null && this.value != null) this.internals.setFormValue(this.value) else this.internals.setFormValue(null) // form* overrides: stash the form's attributes, apply the button's, and // restore after capture. Guard against rapid re-entrant clicks so initial // attributes are never clobbered by in-flight overrides. let entry = activeFormSubmissions.get(form) if (!entry) { const saved = new Map<string, string | null>() const stash = (attr: string) => saved.set(attr, form.getAttribute(attr)) stash('action') stash('method') stash('enctype') stash('target') stash('novalidate') entry = { count: 1, saved } activeFormSubmissions.set(form, entry) } else { entry.count += 1 } if (this.formAction != null) form.setAttribute('action', this.formAction) if (this.formMethod != null) form.setAttribute('method', this.formMethod) if (this.formEnctype != null) form.setAttribute('enctype', this.formEnctype) if (this.formTarget != null) form.setAttribute('target', this.formTarget) if (this.formNoValidate) form.setAttribute('novalidate', '') let restored = false const restore = () => { if (restored) return restored = true const currentEntry = activeFormSubmissions.get(form) if (currentEntry) { currentEntry.count -= 1 if (currentEntry.count <= 0) { activeFormSubmissions.delete(form) for (const [attr, val] of currentEntry.saved) { if (val == null) form.removeAttribute(attr) else form.setAttribute(attr, val) } } } this.internals.setFormValue(null) form.removeEventListener('formdata', restore) } form.addEventListener('formdata', restore, { once: true }) setTimeout(restore, 0) form.requestSubmit() } // React ButtonGroup's `[&>[data-slot=button]…]` rules, applied from inside. private groupClasses() { const pos = this.groupPosition if (!pos) return '' const v = this.groupOrientation === 'vertical' const rounding = { first: v ? 'rounded-t-md rounded-b-none' : 'rounded-l-md rounded-r-none', middle: 'rounded-none', last: v ? 'rounded-t-none rounded-b-md' : 'rounded-l-none rounded-r-md', only: 'rounded-md', }[pos] const joined = pos === 'middle' || pos === 'last' const divider = joined ? ({ default: 'border-primary-foreground/20', secondary: 'border-border', destructive: 'border-white/20' } as Record<string, string>)[ this.variant ] : undefined return cn( 'relative hover:z-10 focus-visible:z-20', rounding, joined && (v ? '-mt-px' : '-ml-px'), divider && [divider, v ? 'border-t' : 'border-l'], ) } render() { const classes = cn( buttonVariants({ variant: this.variant, size: this.size }), // `grow`: fill the host when a layout stretches it (flex-col, w-full). 'grow data-[size=default]:data-[icon]:px-3 data-[size=sm]:data-[icon]:px-2.5 data-[size=lg]:data-[icon]:px-4 data-[size=xs]:data-[icon]:px-1.5', this.groupClasses(), ) const slot = html`<slot class="[&::slotted(svg)]:pointer-events-none [&::slotted(svg)]:shrink-0 [&::slotted(svg:not([class*='size-']))]:size-4" @slotchange=${this.onSlotChange} ></slot>` if (this.href) { return html`<a part="base" class=${classes} href=${this.disabled ? nothing : this.href} target=${this.target ?? nothing} aria-disabled=${this.disabled ? 'true' : nothing} aria-expanded=${this.fwdExpanded ?? nothing} aria-haspopup=${this.fwdHaspopup ?? nothing} aria-description=${this.fwdDescription ?? nothing} aria-label=${this.accessibleLabel ?? nothing} data-size=${this.size} ?data-icon=${this.hasIcon} >${slot}</a >` } return html`<button part="base" class=${classes} type=${this.type} name=${this.name ?? nothing} value=${this.value ?? nothing} formaction=${this.formAction ?? nothing} formmethod=${this.formMethod ?? nothing} formenctype=${this.formEnctype ?? nothing} ?formnovalidate=${this.formNoValidate} formtarget=${this.formTarget ?? nothing} ?disabled=${this.disabled} aria-expanded=${this.fwdExpanded ?? nothing} aria-haspopup=${this.fwdHaspopup ?? nothing} aria-description=${this.fwdDescription ?? nothing} aria-label=${this.accessibleLabel ?? nothing} data-size=${this.size} ?data-icon=${this.hasIcon} @click=${this.onClick} > ${slot} </button>` } } customElements.get('uip-button') || customElements.define('uip-button', UipButton) declare global { interface HTMLElementTagNameMap { 'uip-button': UipButton } }src/uip/components/button/button.variants.ts2.7 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts`) so `Button.vue` can `import { buttonVariants } from * './button.variants'` without creating a circular dependency through the * index. See card.variants.ts for the same pattern + the SSR symptom that * motivated the split. */ export const buttonVariants = cva( "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,background-color,border-color,box-shadow,opacity,transform,scale] duration-150 active:scale-[0.97] active:duration-100 touch-manipulation disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', destructive: 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', xs: 'h-7 rounded-md gap-1 px-2 has-[>svg]:px-1.5 text-xs', icon: 'size-9', 'icon-sm': 'size-8', 'icon-lg': 'size-10', // Square counterpart of `xs` (h-7). Dense data toolbars run at 28px and // had no icon-only tier to match, so icon controls sat 4px taller than // the buttons beside them. Icons inside carry their own size class. 'icon-xs': 'size-7', // Row-action tier: the hover-revealed copy control inside a two-line // table cell. Anything taller than the 20px text line it sits on makes // the row grow when the control appears. 'icon-2xs': 'size-5', }, }, defaultVariants: { variant: 'default', size: 'default', }, }, ) export type ButtonVariants = VariantProps<typeof buttonVariants>src/uip/components/button-group/button-group.ts3.7 kB
import { LitElement, css, html } from 'lit' import { cn } from '../../lib/utils' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' type Orientation = 'horizontal' | 'vertical' export type ButtonGroupPosition = 'first' | 'middle' | 'last' | 'only' const trueByDefault = { fromAttribute: (v: string | null) => v !== null && v !== 'false' } /** * <uip-button-group> — the registry ButtonGroup as a web component. * * React joins its buttons with child selectors (`[&>[data-slot=button]]:rounded-none`, * `:first-child` → `rounded-l-md`, `:not(:first-child)` → `-ml-px` + a divider border). * Those can't reach the inner <button> inside uip-button's shadow root, so the group * tells each direct child where it sits instead: it sets * `data-group-position="first|middle|last|only"` and `data-group-orientation` on every * element child (on `slotchange` and whenever `orientation` / `attached` change), and * uip-button maps them to React's exact rounding / border / negative-margin classes on * its inner button. Native <button> children are not styled (use uip-button). * * Props: `orientation` (horizontal | vertical, default horizontal), `attached` * (default true; `attached="false"` spaces the buttons with React's gap instead). * The host has role="group" — give it an `aria-label`. */ export class UipButtonGroup extends LitElement { // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: inline-flex; }`] static properties = { orientation: { reflect: true }, attached: { converter: trueByDefault }, } orientation: Orientation = 'horizontal' attached = true private internals = this.attachInternals() private marked = new Set<Element>() constructor() { super() new ThemeController(this) this.internals.role = 'group' } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'button-group') } protected willUpdate() { this.setAttribute('data-orientation', this.orientation) this.toggleAttribute('data-attached', this.attached) } protected updated() { this.markChildren() } private markChildren() { const children = [...this.children] for (const el of this.marked) { if (children.includes(el)) continue el.removeAttribute('data-group-position') el.removeAttribute('data-group-orientation') } this.marked = new Set(this.attached ? children : []) children.forEach((el, i) => { if (!this.attached) { el.removeAttribute('data-group-position') el.removeAttribute('data-group-orientation') return } const last = children.length - 1 const position: ButtonGroupPosition = last === 0 ? 'only' : i === 0 ? 'first' : i === last ? 'last' : 'middle' el.setAttribute('data-group-position', position) el.setAttribute('data-group-orientation', this.orientation === 'vertical' ? 'vertical' : 'horizontal') }) } render() { const vertical = this.orientation === 'vertical' return html`<div part="base" data-slot="button-group" data-orientation=${vertical ? 'vertical' : 'horizontal'} ?data-attached=${this.attached} class=${cn( 'inline-flex items-center', vertical ? 'flex-col items-stretch' : 'flex-row', !this.attached && (vertical ? 'gap-1' : 'gap-1.5'), )} > <slot @slotchange=${() => this.markChildren()}></slot> </div>` } } customElements.get('uip-button-group') || customElements.define('uip-button-group', UipButtonGroup) declare global { interface HTMLElementTagNameMap { 'uip-button-group': UipButtonGroup } }
Raw manifest:https://uipkge.dev/r/lit/button.json