{"id":8789,"date":"2026-03-03T11:14:49","date_gmt":"2026-03-03T16:14:49","guid":{"rendered":"https:\/\/frontendmasters.com\/blog\/?p=8789"},"modified":"2026-04-02T16:25:36","modified_gmt":"2026-04-02T21:25:36","slug":"post-mortem-rewriting-agnosticui-with-lit-web-components","status":"publish","type":"post","link":"https:\/\/frontendmasters.com\/blog\/post-mortem-rewriting-agnosticui-with-lit-web-components\/","title":{"rendered":"Post Mortem: Rewriting AgnosticUI with Lit Web Components"},"content":{"rendered":"\n<p>It&#8217;s Fall 2025. I&#8217;m burnt out on my stack. My side projects are going nowhere. I was questioning whether I even <em>liked<\/em> web development anymore.<\/p>\n\n\n\n<p>So I did what any self-respecting developer does: opened up the console and indexed a lookup table with <code>Math.random()<\/code>:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">const<\/span> options = &#91;\n  <span class=\"hljs-string\">\"Grind LeetCode. Hate life. Land FAANG.\"<\/span>,\n  <span class=\"hljs-string\">\"Hard pivot to PM or Design.\"<\/span>,\n  <span class=\"hljs-string\">\"Quit. Live off the land.\"<\/span>,\n];\n\n<span class=\"hljs-keyword\">const<\/span> nextMove = options&#91;<span class=\"hljs-built_in\">Math<\/span>.floor(<span class=\"hljs-built_in\">Math<\/span>.random() * options.length)];<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>There was just one problem with that. <\/p>\n\n\n\n<p><code>!options.includes(correctAnswer)<\/code><\/p>\n\n\n\n<p>I came up with a better move for myself: <strong>actually finish what I&#8217;d already started.<\/strong> So I dusted off <a href=\"https:\/\/agnosticui.com\/\">AgnosticUI<\/a>, a project I&#8217;d started in 2020 and needed a modern update. <\/p>\n\n\n\n<p>The first version of AgnosticUI had solved a real problem: branding consistency across React, Vue, Svelte, and Angular. But keeping JSX, Vue, and Svelte SFC components, <em>and<\/em> ViewEncapsulation in sync across a single CSS source of truth was an absolute maintenance nightmare. I almost archived the repo, but I had unfinished business.<\/p>\n\n\n\n<p>Some DM exchanges with <a href=\"https:\/\/www.abeautifulsite.net\/cv\/\">Cory LaViska (the creator of Shoelace)<\/a> nudged me toward Web Components as the right primitive for the job.<\/p>\n\n\n\n<p><strong>The Plan<\/strong>: A full rewrite using <a href=\"https:\/\/lit.dev\/\">Lit<\/a> with the following non-negotiables:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Close the loop<\/strong>. Double v1 component coverage. Finish what I started.<\/li>\n\n\n\n<li><strong>Platform over Framework<\/strong>. Leverage Lit and Web Components as the foundation. Embrace the platform.<\/li>\n\n\n\n<li><strong>Disciplined AI<\/strong>. A solid library has patterns, conventions, and clean interfaces. Use AI for speed, but own the architecture so the codebase doesn&#8217;t turn to slop.<\/li>\n\n\n\n<li><strong>Focused Scope<\/strong>. I&#8217;m a single human. Hard choices and narrow scope: no data grids, no complex versioning. Just get it done.<\/li>\n\n\n\n<li><strong>Zero expectations<\/strong>. Accept that this might reach zero people and make zero dollars.<\/li>\n<\/ul>\n\n\n\n<p>The work itself had to be the point.<\/p>\n\n\n<div class=\"box article-series\">\n  <header>\n    <h3 class=\"article-series-header\">Article Series<\/h3>\n  <\/header>\n  <div class=\"box-content\">\n            <ol>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/post-mortem-rewriting-agnosticui-with-lit-web-components\/\">Post Mortem: Rewriting AgnosticUI with Lit Web Components<\/a>\n            <\/li>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/form-associated-custom-elements-in-practice\/\">Form-Associated Custom Elements in Practice<\/a>\n            <\/li>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/shadow-dom-focus-delegation-getting-delegatesfocus-right\/\">Shadow DOM Focus Delegation: Getting\u00a0delegatesFocus\u00a0Right<\/a>\n            <\/li>\n                  <\/ol>\n        <\/div>\n<\/div>\n\n\n\n<h2 class=\"wp-block-heading\">Web Components in 2026<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Framework Support is a Non-Issue<\/h3>\n\n\n\n<p>One concern that used to follow Web Components was framework compatibility. The website <a href=\"https:\/\/custom-elements-everywhere.com\/\">custom-elements-everywhere.com<\/a> tracks this, and as of 2026, scores are high across the board. While React 19 now gets a perfect score, I feel that <code>@lit\/react<\/code> still improves DX significantly. More on that shortly.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Encapsulation Without a Black Box<\/h3>\n\n\n\n<p>As a Lit web components noob, the central question surfaced fast: encapsulation is great, but how do consumers customize anything?<\/p>\n\n\n\n<p>The answer is <code><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/CSS\/::part\">::part<\/a><\/code>. <\/p>\n\n\n\n<p>Encapsulation is a feature. The Shadow DOM boundary keeps your components visually consistent regardless of the CSS on the host page, and for a design system, that&#8217;s the whole game. But consumers still need styling hooks for the essentials: colors, padding, and border radii.<\/p>\n\n\n\n<p>CSS custom properties get you partway there. You expose <code>--ag-*<\/code> tokens, and consumers override them. But custom properties only work where you&#8217;ve anticipated them. For anything else, the shadow DOM is a wall.<\/p>\n\n\n\n<p><code>::part<\/code> punches a deliberate hole in that wall:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-comment\">&lt;!-- AgInput exposes named parts --&gt;<\/span>\n<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">input<\/span>\n  <span class=\"hljs-attr\">part<\/span>=<span class=\"hljs-string\">\"ag-input\"<\/span>\n  <span class=\"hljs-attr\">...<\/span>\n\/&gt;<\/span>\n<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">label<\/span>\n  <span class=\"hljs-attr\">part<\/span>=<span class=\"hljs-string\">\"ag-input-label\"<\/span>\n  <span class=\"hljs-attr\">...<\/span>\n\/&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>A consumer can now target those parts directly from outside the shadow DOM:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">ag-input<\/span><span class=\"hljs-selector-pseudo\">::part(ag-input)<\/span> {\n  <span class=\"hljs-attribute\">border-radius<\/span>: <span class=\"hljs-number\">999px<\/span>;\n  <span class=\"hljs-attribute\">border-color<\/span>: hotpink;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>No leaking internals. No <code>!important<\/code> wars. Clean styling hooks, nothing more.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1091\" height=\"579\" src=\"https:\/\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/552818175-df4d4246-e4fe-4154-a77d-fab174b29b3e.svg\" alt=\"Diagram showing CSS arrows bouncing off a solid Shadow DOM wall versus entering through a ::part portal.\" class=\"wp-image-8796\"\/><figcaption class=\"wp-element-caption\">The &#8220;encapsulation wall&#8221; of the Shadow DOM vs. the intentional &#8220;surgical entry points&#8221; provided by <code>::part<\/code>.<\/figcaption><\/figure>\n\n\n\n<p>Here&#8217;s a minimal working example showing both the token override and <code>::part<\/code> approach together:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">ag-input<\/span> <span class=\"hljs-attr\">label<\/span>=<span class=\"hljs-string\">\"Email\"<\/span> <span class=\"hljs-attr\">placeholder<\/span>=<span class=\"hljs-string\">\"you@example.com\"<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">ag-input<\/span>&gt;<\/span>\n\n<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">style<\/span>&gt;<\/span><span class=\"css\">\n  <span class=\"hljs-comment\">\/* Custom properties: broad-stroke theming via exposed --ag-* tokens.\n     Overriding these affects the entire system \u2014 any component consuming\n     --ag-space-2 will reflect the change, not just this one. *\/<\/span>\n  <span class=\"hljs-selector-pseudo\">:root<\/span> {\n    <span class=\"hljs-attribute\">--ag-space-2<\/span>: <span class=\"hljs-number\">0.5rem<\/span>;\n    <span class=\"hljs-attribute\">--ag-space-3<\/span>: <span class=\"hljs-number\">0.75rem<\/span>;\n    <span class=\"hljs-attribute\">--ag-border-subtle<\/span>: <span class=\"hljs-number\">#cbd5e1<\/span>;\n    <span class=\"hljs-attribute\">--ag-text-primary<\/span>: <span class=\"hljs-number\">#0f172a<\/span>;\n    <span class=\"hljs-attribute\">--ag-background-primary<\/span>: <span class=\"hljs-number\">#ffffff<\/span>;\n    <span class=\"hljs-attribute\">--ag-font-size-sm<\/span>: <span class=\"hljs-number\">0.875rem<\/span>;\n  }\n\n  <span class=\"hljs-comment\">\/* ::part: surgical overrides for what tokens can't reach.\n     Targets named parts the component explicitly exposes \u2014\n     everything else in the shadow DOM remains untouchable. *\/<\/span>\n  <span class=\"hljs-selector-tag\">ag-input<\/span><span class=\"hljs-selector-pseudo\">::part(ag-input)<\/span> {\n    <span class=\"hljs-attribute\">border-radius<\/span>: <span class=\"hljs-number\">999px<\/span>;\n    <span class=\"hljs-attribute\">border-color<\/span>: hotpink;\n  }\n\n  <span class=\"hljs-selector-tag\">ag-input<\/span><span class=\"hljs-selector-pseudo\">::part(ag-input-label)<\/span> {\n    <span class=\"hljs-attribute\">font-weight<\/span>: <span class=\"hljs-number\">700<\/span>;\n    <span class=\"hljs-attribute\">color<\/span>: hotpink;\n  }\n<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">style<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_vEKoxbM\" src=\"\/\/codepen.io\/anon\/embed\/vEKoxbM?height=250&amp;theme-id=1&amp;slug-hash=vEKoxbM&amp;default-tab=result\" height=\"250\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed vEKoxbM\" title=\"CodePen Embed vEKoxbM\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<h3 class=\"wp-block-heading\">The A11y Trade-Off<\/h3>\n\n\n\n<p>Notice the <code>label<\/code> in that example? It lives inside the shadow root, not in the light DOM where you&#8217;d expect it.<\/p>\n\n\n\n<p>In standard HTML, a <code>&lt;label for=\"some-id\"&gt;<\/code> connects to an <code>&lt;input id=\"some-id\"&gt;<\/code> across the document. Shadow DOM breaks that contract. The <code>for<\/code>\/<code>id<\/code> association doesn&#8217;t cross the boundary.<\/p>\n\n\n\n<p>The workaround: own the entire form control inside the shadow DOM. Label, input, helper text, error message; all of it lives together, wired up with internally-generated IDs:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-comment\">&lt;!-- Both label and input share IDs generated at component instantiation --&gt;<\/span>\n<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">label<\/span>\n  <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">\"${this._ids.labelId}\"<\/span>\n  <span class=\"hljs-attr\">for<\/span>=<span class=\"hljs-string\">\"${this._ids.inputId}\"<\/span>\n  <span class=\"hljs-attr\">part<\/span>=<span class=\"hljs-string\">\"ag-input-label\"<\/span>\n&gt;<\/span>\n  ${this.label}\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">label<\/span>&gt;<\/span>\n\n<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">input<\/span>\n  <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">\"${this._ids.inputId}\"<\/span>\n  <span class=\"hljs-attr\">aria-describedby<\/span>=<span class=\"hljs-string\">\"${this._getAriaDescribedBy()}\"<\/span>\n  <span class=\"hljs-attr\">...<\/span>\n\/&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Consumers can&#8217;t relocate the <code>label<\/code>, but <code>part=\"ag-input-label\"<\/code> means they can restyle it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Final Frontier: Form Participation<\/h2>\n\n\n\n<p>The shadow DOM a11y trade-offs were covered above. But there&#8217;s an additional, thornier problem: native form participation.<\/p>\n\n\n\n<p>The line <code><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/HTMLElement\/attachInternals\">static formAssociated = true<\/a><\/code> sounds like a declaration of intent, but it&#8217;s just an opt-in signal to the browser. The actual work requires <code><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/HTMLElement\/attachInternals\">attachInternals()<\/a><\/code>, and then you&#8217;re on the hook for reimplementing behaviors the browser gives native inputs for free: <code>required<\/code>, <code>disabled<\/code>, validation state, form reset, value submission.<\/p>\n\n\n\n<p><code><a href=\"https:\/\/www.agnosticui.com\/components\/input.html\">AgInput<\/a><\/code> doesn&#8217;t fully implement this yet. Open ticket: <a href=\"https:\/\/github.com\/AgnosticUI\/agnosticui\/issues\/274\">Issue #274<\/a>, captured and ready to tackle. Once resolved, the <code>Experimental<\/code> badges can finally come down.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The DX Reality Check: React 19 vs. @lit\/react<\/h2>\n\n\n\n<p>Web components are framework-agnostic by design, but that doesn&#8217;t mean &#8220;frictionless everywhere.&#8221; React is the obvious stress test.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The Raw React 19 Experience<\/h3>\n\n\n\n<p><a href=\"https:\/\/react.dev\/blog\/2024\/12\/05\/react-19\">React 19<\/a> made genuine progress on web component support, but consuming a web component directly in JSX still surfaces paper cuts that accumulate fast.<\/p>\n\n\n\n<p>Consider using <code>&lt;ag-input&gt;<\/code> directly in a React 19 app:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Raw React 19: web component consumed directly<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">default<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">RawExample<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> inputRef = useRef(<span class=\"hljs-literal\">null<\/span>);\n\n  useEffect(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n    <span class=\"hljs-comment\">\/\/ Custom events must be wired manually via ref in React 18 and below.<\/span>\n    <span class=\"hljs-comment\">\/\/ React 19 adds declarative support, but event names must match exactly<\/span>\n    <span class=\"hljs-comment\">\/\/ including case and use the on prefix. Easy to get wrong.<\/span>\n    <span class=\"hljs-keyword\">const<\/span> el = inputRef.current;\n    el?.addEventListener(<span class=\"hljs-string\">\"ag-change\"<\/span>, handleChange);\n    <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> el?.removeEventListener(<span class=\"hljs-string\">\"ag-change\"<\/span>, handleChange);\n  }, &#91;]);\n\n  <span class=\"hljs-keyword\">return<\/span> (\n    <span class=\"hljs-comment\">\/\/ kebab-case required: JSX won't recognize PascalCase for custom elements<\/span>\n    <span class=\"hljs-comment\">\/\/ Boolean props must be passed as strings or omitted entirely<\/span>\n    <span class=\"hljs-comment\">\/\/ camelCase props like labelPosition may silently fail; React 18 lowercases<\/span>\n    <span class=\"hljs-comment\">\/\/ them to labelposition; React 19 checks for a matching property first<\/span>\n    <span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">ag-input<\/span>\n      <span class=\"hljs-attr\">ref<\/span>=<span class=\"hljs-string\">{inputRef}<\/span>\n      <span class=\"hljs-attr\">label<\/span>=<span class=\"hljs-string\">\"Email\"<\/span>\n      <span class=\"hljs-attr\">label-position<\/span>=<span class=\"hljs-string\">\"top\"<\/span>\n      <span class=\"hljs-attr\">placeholder<\/span>=<span class=\"hljs-string\">\"you@example.com\"<\/span>\n      <span class=\"hljs-attr\">required<\/span>\n    &gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">ag-input<\/span>&gt;<\/span><\/span> <span class=\"hljs-comment\">\/\/ explicit closing tag required; self-closing silently breaks<\/span>\n  );\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Together, they&#8217;re a DX tax that requires knowing which React version you&#8217;re on.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The @lit\/react Wrapper<\/h3>\n\n\n\n<p>The <code><a href=\"https:\/\/lit.dev\/docs\/frameworks\/react\/\">@lit\/react<\/a><\/code> <code>createComponent<\/code> wrapper eliminates the entire surface area of those problems. Here&#8217;s the actual wrapper for <code>AgInput<\/code>:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> * <span class=\"hljs-keyword\">as<\/span> React <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"react\"<\/span>;\n<span class=\"hljs-keyword\">import<\/span> { createComponent } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"@lit\/react\"<\/span>;\n<span class=\"hljs-keyword\">import<\/span> { AgInput, type InputProps } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"..\/core\/Input\"<\/span>;\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">const<\/span> ReactInput = createComponent({\n  <span class=\"hljs-attr\">tagName<\/span>: <span class=\"hljs-string\">\"ag-input\"<\/span>,\n  <span class=\"hljs-attr\">elementClass<\/span>: AgInput,\n  <span class=\"hljs-attr\">react<\/span>: React,\n  <span class=\"hljs-attr\">events<\/span>: {\n    <span class=\"hljs-comment\">\/\/ Native events (click, input, change, focus, blur) work automatically.<\/span>\n    <span class=\"hljs-comment\">\/\/ No mapping needed.<\/span>\n  },\n});<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>And consuming it:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ @lit\/react wrapper: standard React DX, no web component roughness<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">default<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">WrappedExample<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n  <span class=\"hljs-keyword\">return<\/span> (\n    <span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">ReactInput<\/span>\n      <span class=\"hljs-attr\">label<\/span>=<span class=\"hljs-string\">\"Email\"<\/span>\n      <span class=\"hljs-attr\">labelPosition<\/span>=<span class=\"hljs-string\">\"top\"<\/span>\n      <span class=\"hljs-attr\">placeholder<\/span>=<span class=\"hljs-string\">\"you@example.com\"<\/span>\n      <span class=\"hljs-attr\">required<\/span>\n      <span class=\"hljs-attr\">onChange<\/span>=<span class=\"hljs-string\">{(e)<\/span> =&gt;<\/span> console.log(e.target.value)}\n    \/&gt;<\/span>\n  );\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>PascalCase component name. camelCase props. Self-closing syntax. Native event handlers wired up like any other React component. Thin wrapper, big DX win.<\/p>\n\n\n\n<p>React 19 narrowed the gap. <code>@lit\/react<\/code> closes it.<\/p>\n\n\n\n<p class=\"learn-more\">AgnosticUI&#8217;s Vue wrappers are hand-rolled <code>.vue<\/code> SFC files. Story for another day.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">CLI &amp; Dogfooding<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">The CLI Move<\/h3>\n\n\n\n<p>Most component libraries ship as npm packages and expect consumers to absorb every update. I wanted to optimize for the consumer instead.<\/p>\n\n\n\n<p>The <a href=\"https:\/\/www.agnosticui.com\/installation.html#agnosticui-cli-recommended\">AgnosticUI CLI<\/a> takes a different approach: rather than installing a versioned package and praying the next update doesn&#8217;t break your overrides, you copy the component source directly into your project. Two commands:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"># One-time project setup\nnpx agnosticui-cli init\n\n# Add the components you actually need\nnpx agnosticui-cli add button input card<\/pre>\n\n\n\n<p>The components land as TypeScript files, readable and modifiable by you or your LLM. Your build tool needs to handle TypeScript compilation (Vite works great). If a future release has something you want, opt in deliberately with another <code>add<\/code>.<\/p>\n\n\n\n<p>The philosophy is simple: own the source, make the LLM&#8217;s job easier.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Reliable Local Dev: Ditch <code>npm link<\/code>, Use <code>npm pack<\/code><\/h3>\n\n\n\n<p><code>npm link<\/code> is the obvious tool for local package development. It&#8217;s also, in my experience, a reliable source of subtle bugs: symlink resolution issues, mismatched peer dependencies, stale module caches.<\/p>\n\n\n\n<p>The <code>npm pack<\/code> tarball workflow is slightly slower but more trustworthy.<\/p>\n\n\n\n<p>My typical workflow across two terminal tabs:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"Bash\" data-shcb-language-slug=\"bash\"><span><code class=\"hljs language-bash\"><span class=\"hljs-comment\"># Tab 1: in the library root<\/span>\n<span class=\"hljs-comment\"># Run all checks, then pack a fresh tarball<\/span>\nnpm run lint &amp;&amp; npm run typecheck &amp;&amp; npm run <span class=\"hljs-built_in\">test<\/span> &amp;&amp; npm run build &amp;&amp; npm pack\n<span class=\"hljs-comment\"># Produces: agnosticui-core-2.0.0-alpha.&#91;VERSION].tgz<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">Bash<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">bash<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"Bash\" data-shcb-language-slug=\"bash\"><span><code class=\"hljs language-bash\"><span class=\"hljs-comment\"># Tab 2: in the consuming app (docs site, playbook, or test project)<\/span>\nnpm run clear:cache &amp;&amp; npm run reinstall:lib &amp;&amp; npm run docs:dev\n\n<span class=\"hljs-comment\"># Or install directly by path<\/span>\nnpm install ..\/..\/lib\/agnosticui-core-2.0.0-alpha.13.tgz<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">Bash<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">bash<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>I use this for all consumer tests: Storybooks, Kitchen Sink spot testing, CLI testing, and playbooks.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Playbooks<\/h3>\n\n\n\n<p>Playbooks are UI that model real scenarios: a <a href=\"https:\/\/www.agnosticui.com\/playbooks\/login.html\">Login Form<\/a>, an <a href=\"https:\/\/www.agnosticui.com\/playbooks\/onboarding.html\">Onboarding Wizard<\/a>, a <a href=\"https:\/\/www.agnosticui.com\/playbooks\/dashboard.html\">Discovery Dashboard<\/a>. Building the Login playbook isn\u2019t about testing <code>AgInput<\/code>. It\u2019s just about using it. When something feels off, you know immediately.<\/p>\n\n\n\n<p>So, while unit tests may tell you if a component works in isolation, playbooks tell you if it works in practice. That\u2019s the ultimate litmus test and the whole point of dogfooding. Each playbook I shipped sent me back upstream to fix things I otherwise would have missed.<\/p>\n\n\n\n<p>That feedback loop catches things unit tests miss. Each playbook I shipped sent me back upstream to fix something I wouldn&#8217;t have caught otherwise. The components powering these aren&#8217;t just theoretically correct; they&#8217;ve been used and broken in something resembling the real world.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"768\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/68747470733a2f2f7777772e61676e6f7374696375692e636f6d2f706c6179626f6f6b732f4465736b746f702e706e67.png?resize=1024%2C768&#038;ssl=1\" alt=\"AgnosticUI Login Form Playbook: desktop split layout with form on left and mountain background on right\" class=\"wp-image-8837\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/68747470733a2f2f7777772e61676e6f7374696375692e636f6d2f706c6179626f6f6b732f4465736b746f702e706e67.png?resize=1024%2C768&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/68747470733a2f2f7777772e61676e6f7374696375692e636f6d2f706c6179626f6f6b732f4465736b746f702e706e67.png?resize=300%2C225&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/68747470733a2f2f7777772e61676e6f7374696375692e636f6d2f706c6179626f6f6b732f4465736b746f702e706e67.png?resize=768%2C576&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/68747470733a2f2f7777772e61676e6f7374696375692e636f6d2f706c6179626f6f6b732f4465736b746f702e706e67.png?w=1440&amp;ssl=1 1440w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><figcaption class=\"wp-element-caption\">The <a href=\"https:\/\/www.agnosticui.com\/playbooks\/login.html\">Login Form playbook<\/a>. A realistic starting point built entirely with AgnosticUI components.<\/figcaption><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Want to Remix Them?<\/h3>\n\n\n\n<p>The playbooks are designed to be starting points, not finished products. A few ideas:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Edit the playbook&#8217;s corresponding prompt to reposition components, adjust layouts, swap fonts, etc.<\/li>\n\n\n\n<li>Swap in your own images, logos, and color tokens.<\/li>\n\n\n\n<li>Try the approach with a different library entirely. DaisyUI, Chakra-UI, and others should work just as well. The key is being prescriptive enough that the LLM isn&#8217;t left guessing.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p>AgnosticUI v2 isn&#8217;t finished, and it may always be a WIP labor of love. Some components are still marked <code>Experimental<\/code>. Form association is an open ticket.<\/p>\n\n\n\n<p>But the loop is closed.<\/p>\n\n\n\n<p>I ramped up on Lit and Web Components. I used AI effectively without taking my hands off the wheel. I shipped <a href=\"https:\/\/agnosticui.com\">something I can point to<\/a>.<\/p>\n\n\n\n<p>That&#8217;s enough.<\/p>\n\n\n<div class=\"box article-series\">\n  <header>\n    <h3 class=\"article-series-header\">Article Series<\/h3>\n  <\/header>\n  <div class=\"box-content\">\n            <ol>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/post-mortem-rewriting-agnosticui-with-lit-web-components\/\">Post Mortem: Rewriting AgnosticUI with Lit Web Components<\/a>\n            <\/li>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/form-associated-custom-elements-in-practice\/\">Form-Associated Custom Elements in Practice<\/a>\n            <\/li>\n                      <li>\n              <a href=\"https:\/\/frontendmasters.com\/blog\/shadow-dom-focus-delegation-getting-delegatesfocus-right\/\">Shadow DOM Focus Delegation: Getting\u00a0delegatesFocus\u00a0Right<\/a>\n            <\/li>\n                  <\/ol>\n        <\/div>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>A framework-agnostic component library, designed to be styled. It can be done.<\/p>\n","protected":false},"author":45,"featured_media":8841,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[3,382,447,36],"class_list":["post-8789","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-javascript","tag-lit","tag-patterns","tag-web-components"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/agnosticui.jpg?fit=2000%2C1200&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8789","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/users\/45"}],"replies":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/comments?post=8789"}],"version-history":[{"count":16,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8789\/revisions"}],"predecessor-version":[{"id":9197,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8789\/revisions\/9197"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media\/8841"}],"wp:attachment":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media?parent=8789"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/categories?post=8789"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/tags?post=8789"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}