DEV Community

Abraham
Abraham

Posted on

Same UI component for all frameworks

Premise

"I love React😍", "I'm more of a vue person myself🤷🏽", "Angular all the way🙌🏽", "Solid js does it for me🙂". "Lol, ya'll just forgot Svelte uhn😏".

Five developers stating their preferences for whatever reasons. What do they have in common? They're all javascript frameworks.

"I love chakra ui", "Vuetify is what's available to me", "I';; go with Angular Material UI", "Imma just stick to different packages", "Well flowbite I guess".

Now that's their respective component libraries of choice.

How could they contribute to each other's development across frameworks? Well it's everyone for himself.

Now that's where Zag JS comes in.

Now react developers says "I found a bug in my Popover component", the rest say "shit, let's fix that".

Which of this conversation will be more beautiful to have? Instead of a cross framework community, they're now all one community, that can work together regardless of framework.

History

Chakra UI is a component library for react. It's beautiful, and just makes development easier and easy going. Chakra UI for vue came along and the experience wasn't really nice. There was a lot of logic used in react, and it had to be copied to the vue version, then reengineered to vue syntax, cause logic that depended on react hooks can't be used seamlessly in vue.

So the creator of Chakra UI. Sage wanted a way to make this seamless, and forth came. Zag JS.

Zag JS

It's a collection of framework-agnostic UI component patterns like accordion, menu, and dialog that can be used to build design systems for React, Vue and Solid.js. And it's powered my state machines.

How does it work? Zag Js uses state machines to manage the state and logic of the components, so they're not reliant on the framework, they don't need useState or useEffect from react. Or createSignal from solid JS.
The machine for the component you need is exported from zag Js and a useMachine method to interpret it and bring it t life.
Then there's a connect method, that accepts the state of the interpreted machine and constructs the attributes and events needed for your dom elements.
The connect method also accepts a normalizeProps method which is exported from your framework adapter in zagjs.
It's job is to resolve the the irregularities across framework attributes' naming.
Now all you need to do is spread the derived props on your html elements.
Well they say, code speaks louder than words, so let's get to it.

We'll practice with a number input component. The native number input is quite resistant to styling, so we'll create one that has all it's attributes, accessibility and functions. But easy to style.

First, you install the component.

npm install @zag-js/number-input
Enter fullscreen mode Exit fullscreen mode

One good thing about this, is you don't need to pull in a bunch of components you might not use. Just install the one you need. In this case we're only installing number input.

Then we install the adapter for our framework

npm @zag-js/react
//or
npm @zag-js/vue
//or
npm @zag-js/solid
Enter fullscreen mode Exit fullscreen mode

Only three frameworks are available currently, more incoming.
But the requirements for a framework to be accepted is to support props spreading on elements <element {...props}>.

Now let's move on. We've installed the two things we need.
First we import our machine, then we import the useMachine and useSetup methods from our framework adapter

import * as numberInput from "@zag-js/number-input"
import { useMachine, useSetup } from "@zag-js/react"
Enter fullscreen mode Exit fullscreen mode

could be

import { useMachine, useSetup } from "@zag-js/vue"
//or
import { useMachine, useSetup } from "@zag-js/solid"
Enter fullscreen mode Exit fullscreen mode

Next we interprete the machine, which returns the state of the machine and a method to send events to the machine.

const [state, send] = useMachine(numberInput.machine)
Enter fullscreen mode Exit fullscreen mode

Next we setup the machine, this helps zagJs have access to any kinds of DOM, whether an iframe, or an electron dom.

const ref = useSetup({ send, id: "1" })
Enter fullscreen mode Exit fullscreen mode

The ref is attached to the root of your component. If you have multiple instances of the component, give them different IDs

Then we pass the machine state and it's send method to the connect method, which as I said earlier constructs the pieces to bring the elements to life.

const api = numberInput.connect(state, send)
Enter fullscreen mode Exit fullscreen mode

Now all the attributes and events needed to make up our number input are available in the api variable. Let's see the full code.
React:

import * as numberInput from "@zag-js/number-input"
import { useMachine, useSetup } from "@zag-js/react"

export function NumberInput() {
  const [state, send] = useMachine(numberInput.machine)
  const ref = useSetup({ send, id: "1" })
  const api = numberInput.connect(state, send)

  return (
    <div ref={ref} {...api.rootProps}>
      <label {...api.labelProps}>Enter number:</label>
      <div>
        <button {...api.decrementButtonProps}>DEC</button>
        <input {...api.inputProps} />
        <button {...api.incrementButtonProps}>INC</button>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Vue:

<script setup>
import * as numberInput from "@zag-js/number-input";
import { normalizeProps, useMachine, useSetup } from "@zag-js/vue";
import { computed } from "vue";

const [state, send] = useMachine(numberInput.machine);
const ref = useSetup({ send, id: "1" });
const api = computed(() =>
  numberInput.connect(state.value, send, normalizeProps)
);
</script>

<template>
  <div ref="ref" v-bind="api.rootProps">
    <label v-bind="api.labelProps">Enter number</label>
    <div>
      <button v-bind="api.decrementButtonProps">DEC</button>
      <input v-bind="api.inputProps" />
      <button v-bind="api.incrementButtonProps">INC</button>
    </div>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Solid:

import * as numberInput from "@zag-js/number-input"
import { normalizeProps, useMachine, useSetup } from "@zag-js/solid"
import { createMemo, createUniqueId } from "solid-js"

export function NumberInput() {
  const [state, send] = useMachine(numberInput.machine)
  const ref = useSetup({ send, id: createUniqueId() })
  const api = createMemo(() => numberInput.connect(state, send, normalizeProps))

  return (
    <div ref={ref} {...api().rootProps}>
      <label {...api().labelProps}>Enter number:</label>
      <div>
        <button {...api().decrementButtonProps}>DEC</button>
        <input {...api().inputProps} />
        <button {...api().incrementButtonProps}>INC</button>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

See how in each of these frameworks, we just get what we need from api. Cool right?

Now api also provides some other methods we might need.

api.value
// => "12"

api.valueAsNumber
// => 12

api.setValue("300")
api.clearValue() // => new value: ""
api.increment() // => new value: "1"
api.decrement() // => new value: "0"

api.setToMax() // => new value: "100"
api.setToMin() // => new value: "0"
api.focus()
Enter fullscreen mode Exit fullscreen mode

Still cool right?

Now styling. Quite same across all frameworks. Every part of the component already has data attributes from the props api just provided them, which can be used for styling.

[data-part="root"]{

}

[data-part="input"]{

}

[data-part="label"]{

}

[data-part="spin-button"]{
  // for decrement and increment buttons
}
Enter fullscreen mode Exit fullscreen mode

You can also style based on states of the component.

Disabled state:

[data-part="root"][data-disabled] {
  /* disabled styles for the input */
}

[data-part="input"][data-disabled] {
  /* disabled styles for the input */
}

[data-part="label"][data-disabled] {
  /* disabled styles for the label */
}

[data-part="spin-button"][data-disabled] {
  /* disabled styles for the spin buttons */
}
Enter fullscreen mode Exit fullscreen mode

Invalid state:

[data-part="root"][data-invalid] {
  /* disabled styles for the input */
}

[data-part="input"][data-invalid] {
  /* invalid styles for the input */
}

[data-part="label"][data-invalid] {
  /* invalid styles for the label */
}
Enter fullscreen mode Exit fullscreen mode

You can head over to the docs if you want to find out more: https://zagjs.com/

And of course, it's open source 🤪
Repo: https://github.com/chakra-ui/zag
Docs Repo: https://github.com/chakra-ui/zag-docs

Also feel free to contact me if you have questions.😎

Thanks for reading. Have a nice day.🚀

Discussion (0)