Vanilla Extract: Nulis CSS di TypeScript

Vanilla Extract memiliki pondasi yang kuat sebagai solusi styling, tool ini memposisikan dirinya sebagai CSS preprocessor layaknya SASS; Hanya saja menggunakan JavaScript/Typescript sebagai “compiler”-nya.

Dikutip dari website resminya:

Use TypeScript as your preprocessor. Write type-safe, locally scoped classes, variables and themes, then generate static CSS files at build time.

~Vanilla Extract

Saya bukan termasuk orang yang mengagungkan CSS in JS pada umumnya. Tapi kali ini saya hype-banget sama tool satu ini, terutama karena fitur static extraction; yap, tool ini tidak* menggunakan runtime pada penerapan style-nya.

Zero-runtime

CSS in JS banyak dipilih salah satu alasannya karena developer-experience yang diberikan dapat meningkatkan produktifitas si developer. Namun tak jarang malah mengorbankan performance website yang dibuat karena browser client harus parse styles dengan JavaScript.

Meskipun Vanilla Extract bisa dikategorikan CSS in JS, tool ini tidak menambah bundle JavaScript karena semua styles di extract waktu build menjadi file CSS sehingga performance menjadi kelebihan Vanilla Extract, semua tanpa mengorbankan DX yang didapatkan di CSS in JS lain.

// Button.css.ts

import { style } from '@vanilla-extract/css'

const styles = {
  button: style({
    padding: '0.5rem 1.5rem',
    fontWeight: '500',
    color: 'white',
    backgroundColor: 'rebeccapurple',
    borderRadius: '9999px',
    ':hover': {
      backgroundColor: 'blueviolet'
    }
  })
}

export default styles
// Button.tsx

import styles from './Button.css.ts'

const Button = () => {
  return <button className={styles.button}>Button</button>
}

export default Button

Kode tersebut akan di proses oleh Vanilla Extract menjadi:

/* button.css */

._1w6sasr0 {
  padding: 0.5rem 1.5rem;
  font-weight: 500;
  color: #fff;
  background-color: #639;
  border-radius: 9999px;
}
._1w6sasr0:hover {
  background-color: #8a2be2;
}
<!-- button.html -->
<button class="_1w6sasr0">Button</button>

Preview

^coba inspect button ini di devtool ehehe

Demo button diatas bener-bener di buat dengan Vanilla Extract ya, source asli bisa dilihat: Button.css.ts, Button.tsx, dan compiled css disini

Karena output dari Vanilla Extract berupa vanilla CSS, kita bisa proses lebih lanjut dengan PostCSS, misal untuk polyfill browser lama, pakai syntax experimental, atau sekadar autoprefix, dsb.

Namun ada beberapa optional API Vanilla Extract yang membutuhkan runtime minimal seperti:

  • Recipes API untuk toggles prebuilt classes berdasarkan variant props; dan
  • Dynamic API untuk override css variable secara dinamis.

Selain karena zero-runtime, Vanilla Extract memiliki API yang memudahkan kita untuk menerapkan Atomic CSS.

Utility-first Atomic Styling

via Sprinkles API;

Basically, it’s like building your own zero-runtime, type-safe version of Tailwind, Styled System, etc.

Sebelumnya saya pernah menggunakan TailwindCSS dimana setiap class hanya memiliki satu fungsi: “Do one thing, and do it really well.”

Dengan begitu bundle CSS bisa menjadi sangat kecil karena sizenya pindah ke markup HTML aowskaosk tiap class sangat reusable.

Mari buat sprinkles lalu menggunakannya ke stylesheets button kita tadi..

// sprinkles.css.ts

import { defineProperties, createSprinkles } from '@vanilla-extract/sprinkles'

const borderRadius = {
  sm: '0.25rem',
  md: '0.375rem',
  lg: '0.5rem',
  full: '9999px'
}

const properties = defineProperties({
  properties: {
    color: ['white']
  }
})

const interactiveProperties = defineProperties({
  defaultCondition: 'idle',
  conditions: {
    idle: {},
    hover: { selector: '&:hover' }
  },
  properties: {
    backgroundColor: ['rebeccapurple', 'blueviolet'],
    borderRadius
  }
})

export default createSprinkles(properties, interactiveProperties)
// Button.css.ts

import { style } from '@vanilla-extract/css'
import sprinkles from './sprinkles.css'

const styles = {
  button: style([
    {
      padding: '0.5rem 1.5rem',
      fontWeight: '500'
    },
    sprinkles({
      color: 'white',
      borderRadius: 'full',
      backgroundColor: {
        idle: 'rebeccapurple',
        hover: 'blueviolet'
      }
    })
  ])
}

export default styles

output:

/* button.css */

/* sprinkles start */
._10euf70 {
  color: #fff;
}
._10euf71,
._10euf72:hover {
  background-color: #639;
}
._10euf73,
._10euf74:hover {
  background-color: #8a2be2;
}
._10euf75,
._10euf76:hover {
  border-radius: 0.25rem;
}
._10euf77,
._10euf78:hover {
  border-radius: 0.375rem;
}
._10euf7a:hover,
._10euf79 {
  border-radius: 0.5rem;
}
._10euf7b,
._10euf7c:hover {
  border-radius: 9999px;
}
/* sprinkles end */
._1hx7eqw1 {
  padding: 0.5rem 1.5rem;
  font-weight: 500;
}
<!-- button.html -->

<button class="_1hx7eqw1 _10euf70 _10euf7b _10euf71 _10euf74">
  Sprinkles Button
</button>

Bisa dilihat bahwa semua properties di sprinkles akan digenerate oleh Vanilla Extract baik yang digunakan maupun tidak. Hal ini karena sprinkles dapat juga digunakan di client runtime dengan menggunakannya langsung di component kita (bukan file .css.ts), jadi semua possible-styles di sprinkles harus tersedia.

Dari core contributor Vanilla Extract: ”… We like to use Sprinkles for the most common 80% of your styles. …” @mattcompiles di GitHub Discussion

Jadi sprinkles akan efektif ketika style memang digunakan di banyak components, kita harus pandai-pandai mengaturnya. Mari kita hilangkan borderRadius dari sprinkles.

# sprinkles.css.ts

import { defineProperties, createSprinkles } from '@vanilla-extract/sprinkles'

-const borderRadius = {
-  sm: '0.25rem',
-  md: '0.375rem',
-  lg: '0.5rem',
-  full: '9999px'
-}

const properties = defineProperties({
  properties: {
    color: ['white']
  }
})

const interactiveProperties = defineProperties({
  defaultCondition: 'idle',
  conditions: {
    idle: {},
    hover: { selector: '&:hover' }
  },
  properties: {
    backgroundColor: ['rebeccapurple', 'blueviolet'],
-   borderRadius
  }
})

export default createSprinkles(properties, interactiveProperties)
# Button.css.ts

import { style } from '@vanilla-extract/css'
import sprinkles from './sprinkles.css'

const styles = {
  button: style([
    {
      padding: '0.5rem 1.5rem',
      fontWeight: '500',
+     borderRadius: '9999px',
    },
    sprinkles({
      color: 'white',
-     borderRadius: 'full',
      backgroundColor: {
        idle: 'rebeccapurple',
        hover: 'blueviolet'
      }
    })
  ])
}

export default styles

Output setelahnya:

/* button.css */

/* sprinkles start */
._10euf70 {
  color: #fff;
}
._10euf71,
._10euf72:hover {
  background-color: #639;
}
._10euf73,
._10euf74:hover {
  background-color: #8a2be2;
}
/* sprinkles end */
._1hx7eqw1 {
  padding: 0.5rem 1.5rem;
  font-weight: 500;
  border-radius: 999px;
}
<!-- button.html -->

<button class="_1hx7eqw1 _10euf70 _10euf71 _10euf74">Sprinkles Button</button>

Preview

Memang membandingkan tool ini dengan framework seperti Tailwind ngga apple-to-apple karena tujuan mereka sudah berbeda, tapi bagaimanapun Vanilla Extract memiliki kelebihan:

  • Minimal abstraction over standard CSS, ngga usah nge-hafalin class seperti Tailwind. Jujur, ketika pakai Tailwind saya harus selalu buka docs mereka. Buat developer yang “paham” CSS, saya yakin ini jadi salah satu obstacle.
  • Free ClassName Obfuscation, karena output class yang dihasilkan berupa hash, ini akan menjadi nilai plus untuk developer yang menginginkan pekerjaanya tidak mudah di-clone.
  • Type-safe, kalau style dihapus dari sprinkles namun masih menjadi dependency di sebuah component, build akan fail. Refactoring kode juga lebih percaya diri.

Multi-variant Styles

Sebelumnya website ini dibangun dengan Stitches, sebuah CSS in JS dengan near-zero runtime; saya tertarik karena Variants API mereka yang sangat memudahkan styling components.

Hingga saya putuskan untuk migrate sepenuhnya ke Vanilla Extract, karena Vanilla Extract juga release Variants API versi mereka: Recipes API;

Create multi-variant styles with a type-safe runtime API, heavily inspired by Stitches.

Recipes API memudahkan developer dalam membangun component library, semua variasi styles bisa dijadikan satu definisi saja.

// Button.css.ts

import { recipe } from '@vanilla-extract/recipes'

const button = recipe({
  base: {
    // style yang dimiliki oleh semua variants
  },
  variants: {
    rounded: {
      // nama variant dapat berupa boolean
      true: {...}
    },
    color: {
      pink: {...},
      gray: {...},
    }
  },
  compoundVariants: [
    // style ketika dua variant atau lebih yang aktif secara bersamaan
  ],
  defaultVariants: {
    rounded: false,
    color: 'pink'
  }
})

export default { button }

Button Variants



<!-- ButtonVariants.tsx -->

<Button color="pink">Button</Button>
<Button rounded>Button</Button>
<Button color="gray">Button</Button>
<Button color="gray" rounded>Button</Button>
// Button.tsx

import { RecipeVariants } from '@vanilla-extract/recipes'

import button from './Button.css.ts'

function Button({ color, rounded }: RecipeVariants<typeof button>) {
  return <button className={button({ color, rounded })}>Button</button>
}

Dan Recipes API ini bisa dikombinasikan dengan Sprinkles API, sehingga kita bisa re-use atomic styles yang sudah ada untuk compose variants.. mantab..

Berikut sebuah component Button dengan:

  • 3 variant: contained, outlined, text - default
  • 3 size: small, normal - default, large
  • 2 color: pink - default, gray
  • 2 boolean state: loading, disabled
  • 9 rounded variant: false - default, true - full rounded, xs, sm, md, lg, xl, 2xl, 3xl

Akhiran

Vanilla Extract mempunyai segudang fitur modern yang tidak dapat didapatkan di old-school CSS tooling karena penggunaan ekosistem TypeScript nya. Beragam API yang disediakan sangat menunjang produktifitas para developer menjadikan tool ini dapat menggantikan berbagai tool sekaligus.

Banyak fitur dan manfaat lain dari Vanilla Extract yang tidak saya tulis, saya cantumkan beberapa referensi terkait tool ini di bawah.

Terimakasih.