diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for tailwind (Haskell)
+
+## 0.1.0.0 -- 2022-01-08
+
+* First version.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Sridhar Ratnakumar
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,11 @@
+# tailwind
+
+Run TailwindCSS CLI [without needing](https://www.srid.ca/nojs) to touch anything JavaScript. No `input.css` or `tailwind.config.js` necessary.
+
+```
+cabal run tailwind-run -- 'src/**/.hs'
+```
+
+Compiles CSS classes in those file paths or patterns, and writes `./tailwind.css`. Pass `-w` to run in JIT watcher mode.
+
+Used in Emanote, and Ema apps.
diff --git a/exe/Main.hs b/exe/Main.hs
new file mode 100644
--- /dev/null
+++ b/exe/Main.hs
@@ -0,0 +1,48 @@
+{-# LANGUAGE ApplicativeDo #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Main where
+
+import qualified Control.Applicative.Combinators.NonEmpty as NE
+import Control.Lens.Operators
+import Control.Monad.Logger.CallStack (runStdoutLoggingT)
+import Data.Default (Default (def))
+import Main.Utf8 (withUtf8)
+import Options.Applicative
+import System.FilePattern (FilePattern)
+import Web.Tailwind
+
+data Cli = Cli
+  { content :: NonEmpty FilePattern,
+    mode :: Mode
+  }
+  deriving (Eq, Show)
+
+cliParser :: Parser Cli
+cliParser = do
+  content <- NE.some (argument str (metavar "SOURCES..."))
+  mode <- modeParser
+  pure Cli {..}
+
+modeParser :: Parser Mode
+modeParser = do
+  bool Production JIT <$> switch (long "watch" <> short 'w' <> help "Run JIT")
+
+main :: IO ()
+main = do
+  withUtf8 $ do
+    cli <- execParser opts
+    print cli
+    runStdoutLoggingT $
+      runTailwind $
+        def
+          & tailwindConfig . tailwindConfigContent .~ toList (content cli)
+          & tailwindMode .~ mode cli
+  where
+    opts =
+      info
+        (cliParser <**> helper)
+        ( fullDesc
+            <> progDesc "Run Tailwind"
+        )
diff --git a/src/Web/Tailwind.hs b/src/Web/Tailwind.hs
new file mode 100644
--- /dev/null
+++ b/src/Web/Tailwind.hs
@@ -0,0 +1,168 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingVia #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TemplateHaskell #-}
+
+-- | Tailwind runner in Haskell
+--
+-- TODO: Add `main` with CLI parser.
+-- > tailwind-run [-w] 'src/**/*.hs'
+module Web.Tailwind
+  ( -- * Runner
+    runTailwind,
+
+    -- * Types
+    TailwindConfig (..),
+    Tailwind (..),
+    Mode (..),
+    tailwindConfig,
+    tailwindInput,
+    tailwindOutput,
+    tailwindConfigContent,
+    tailwindMode,
+  )
+where
+
+import Control.Lens.TH (makeLenses)
+import Control.Monad.Logger (MonadLogger, logInfoN)
+import Data.Aeson (encode)
+import Data.ByteString (hPut)
+import Data.Default (Default (def))
+import Deriving.Aeson
+import NeatInterpolation (text)
+import System.CPUTime (getCPUTime)
+import System.Directory (doesFileExist)
+import System.IO (hClose)
+import System.Which (staticWhich)
+import Text.Printf (printf)
+import qualified Text.Show
+import UnliftIO (MonadUnliftIO, finally)
+import UnliftIO.Directory (removeFile)
+import UnliftIO.Process (callProcess)
+import UnliftIO.Temporary (withSystemTempFile)
+
+-- | Haskell version of tailwind.config.js
+--
+-- Only the subset we care to define, as some fields (eg: plugins) are defined
+-- with arbitrary JS code.
+data TailwindConfig = TailwindConfig
+  { -- | List of source patterns that reference CSS classes
+    _tailwindConfigContent :: [FilePath]
+  }
+  deriving (Generic)
+  deriving
+    (ToJSON)
+    via CustomJSON
+          '[ FieldLabelModifier
+               '[StripPrefix "_tailwindConfig", CamelToSnake]
+           ]
+          TailwindConfig
+
+newtype Css = Css {unCss :: Text}
+
+data Mode = JIT | Production
+  deriving (Eq, Show)
+
+data Tailwind = Tailwind
+  { _tailwindConfig :: TailwindConfig,
+    _tailwindInput :: Css,
+    _tailwindOutput :: FilePath,
+    _tailwindMode :: Mode
+  }
+
+makeLenses ''TailwindConfig
+makeLenses ''Tailwind
+
+instance Default TailwindConfig where
+  def =
+    TailwindConfig
+      { _tailwindConfigContent = []
+      }
+
+instance Default Tailwind where
+  def =
+    Tailwind
+      { _tailwindConfig = def,
+        _tailwindInput = def,
+        _tailwindOutput = "tailwind.css",
+        _tailwindMode = JIT
+      }
+
+instance Default Css where
+  def =
+    Css
+      [text|
+      @tailwind base;
+      @tailwind components;
+      @tailwind utilities;
+      |]
+
+instance Text.Show.Show TailwindConfig where
+  show (decodeUtf8 . encode -> config) =
+    -- Use `Object.assign` to merge JSON (produced in Haskell) with the rest of
+    -- config (defined by raw JS; that cannot be JSON encoded)
+    toString
+      [text|
+      module.exports =
+        Object.assign(
+          JSON.parse('${config}'),
+          {
+            theme: {
+              extend: {},
+            },
+            plugins: [
+              require('@tailwindcss/typography'),
+              require('@tailwindcss/forms'),
+              require('@tailwindcss/line-clamp'),
+              require('@tailwindcss/aspect-ratio')
+            ],
+          })
+      |]
+
+tailwind :: FilePath
+tailwind = $(staticWhich "tailwind")
+
+modeArgs :: Mode -> [String]
+modeArgs = \case
+  JIT -> ["-w"]
+  Production -> ["--minify"]
+
+runTailwind :: (MonadUnliftIO m, MonadLogger m, HasCallStack) => Tailwind -> m ()
+runTailwind Tailwind {..} = do
+  withTmpFile (show _tailwindConfig) $ \configFile ->
+    withTmpFile (unCss _tailwindInput) $ \inputFile ->
+      callTailwind $ ["-c", configFile, "-i", inputFile, "-o", _tailwindOutput] <> modeArgs _tailwindMode
+  when (_tailwindMode == JIT) $
+    error "Tailwind exited unexpectedly!"
+
+withTmpFile :: MonadUnliftIO m => Text -> (FilePath -> m a) -> m a
+withTmpFile s f = do
+  withSystemTempFile "ema-tailwind-tmpfile" $ \fp h -> do
+    liftIO $ do
+      putStrLn $ "$ cat " <> fp
+      putTextLn s
+      hPut h (encodeUtf8 s) >> hClose h
+    f fp
+      `finally` removeFile fp
+
+callTailwind :: (MonadIO m, MonadLogger m) => [String] -> m ()
+callTailwind args = do
+  logInfoN $ "Running Tailwind compiler with args: " <> show args
+  liftIO (doesFileExist tailwind) >>= \case
+    True ->
+      timeIt $ do
+        callProcess tailwind args
+    False ->
+      error $ "Tailwind compiler not found at " <> toText tailwind
+
+timeIt :: MonadIO m => m b -> m b
+timeIt m = do
+  t0 <- liftIO getCPUTime
+  !x <- m
+  t1 <- liftIO getCPUTime
+  let diff :: Double = fromIntegral (t1 - t0) / (10 ^ (9 :: Integer))
+  liftIO $ printf "Process duration: %0.3f ms\n" diff
+  pure $! x
diff --git a/tailwind.cabal b/tailwind.cabal
new file mode 100644
--- /dev/null
+++ b/tailwind.cabal
@@ -0,0 +1,87 @@
+cabal-version:      2.4
+name:               tailwind
+version:            0.1.0.0
+license:            MIT
+copyright:          2022 Sridhar Ratnakumar
+maintainer:         srid@srid.ca
+author:             Sridhar Ratnakumar
+category:           Web
+
+-- TODO: Before hackage release.
+-- A short (one-line) description of the package.
+synopsis:           Tailwind wrapped in Haskell
+
+-- A longer description of the package.
+description:        Run Tailwind from Haskell without touching JavaScript
+
+-- A URL where users can report bugs.
+-- bug-reports:
+
+extra-source-files:
+  CHANGELOG.md
+  LICENSE
+  README.md
+
+common c
+  mixins:
+    base hiding (Prelude),
+    relude (Relude as Prelude, Relude.Container.One),
+    relude
+
+  ghc-options:
+    -Wall -Wincomplete-record-updates -Wincomplete-uni-patterns
+
+  build-depends:
+    , base          >=4.13.0.0 && <=4.17.0.0
+    , data-default
+    , filepath
+    , filepattern
+    , lens
+    , monad-logger
+    , relude
+
+  default-extensions:
+    FlexibleContexts
+    FlexibleInstances
+    KindSignatures
+    LambdaCase
+    MultiParamTypeClasses
+    MultiWayIf
+    OverloadedStrings
+    ScopedTypeVariables
+    TupleSections
+    ViewPatterns
+
+  default-language:   Haskell2010
+
+library
+  import:          c
+  hs-source-dirs:  src
+  exposed-modules: Web.Tailwind
+  build-depends:
+    , aeson
+    , async
+    , bytestring
+    , containers
+    , deriving-aeson
+    , directory
+    , mtl
+    , neat-interpolation
+    , profunctors
+    , safe-exceptions
+    , temporary
+    , text
+    , time
+    , unliftio
+    , which
+    , with-utf8
+
+executable tailwind-run
+  import:         c
+  hs-source-dirs: exe
+  main-is:        Main.hs
+  build-depends:
+    , optparse-applicative
+    , parser-combinators
+    , tailwind
+    , with-utf8
