diff --git a/hikaru.cabal b/hikaru.cabal
index 081c2ed9b443e738e9c36076c75bb8c41d4cfd62..47a62fe2b0f8a7e48066a4026165e72a009c6816 100644
--- a/hikaru.cabal
+++ b/hikaru.cabal
@@ -37,6 +37,7 @@ common common
         DataKinds
         DefaultSignatures
         DeriveGeneric
+        DuplicateRecordFields
         EmptyDataDecls
         FlexibleContexts
         FlexibleInstances
@@ -53,6 +54,8 @@ common common
         RecordWildCards
         ScopedTypeVariables
         StandaloneDeriving
+        TupleSections
+        TypeApplications
         UndecidableInstances
 
     ghc-options:
@@ -98,6 +101,7 @@ library
         Hikaru.Config
         Hikaru.CSRF
         Hikaru.Dispatch
+        Hikaru.Widget
         Hikaru.Form
         Hikaru.Link
         Hikaru.Localize
diff --git a/lib/Hikaru/Widget.hs b/lib/Hikaru/Widget.hs
new file mode 100644
index 0000000000000000000000000000000000000000..bd1a456ddd3c4dd968ee7b5dbcfa5b73ed94be6e
--- /dev/null
+++ b/lib/Hikaru/Widget.hs
@@ -0,0 +1,226 @@
+-- |
+-- Module      :  Hikaru.Widget
+-- Copyright   :  Jan Hamal Dvořák
+-- License     :  MIT
+--
+-- Maintainer  :  mordae@anilinux.org
+-- Stability   :  unstable
+-- Portability :  non-portable (ghc)
+--
+-- This module tries to simplify building custom HTML widgets that react
+-- to query string parameters and/or form fields and render with different
+-- CSS frameworks such as Bootstrap or Bulma.
+--
+-- The primary user is the "Hikaru.Form" module, but you are welcome to
+-- used it for any interactive content such as the filtered, sorted and
+-- paged tables all applications seem to eventually grow.
+--
+-- Example:
+--
+-- @
+-- -- Alias for Bootstrap 5 in our case.
+-- data B5 = B5
+--
+-- -- Base type for our widget.
+-- data RedText = RedText (Maybe Text)
+--
+-- -- Nothing fancy, just render a piece of text in red.
+-- instance (Monad m) => 'Render' B5 m RedText where
+--   'render' (RedText Nothing) = \"\"
+--   'render' (RedText (Just text)) = do
+--     span_ [style_ \"color: red\"] do
+--       'toHtml' text
+--
+-- -- Smart constructor that can take a default.
+-- redText :: (Monad m) => Maybe Text -> 'WidgetT' m RedText
+-- redText dfl = do
+--   text <- 'getArgumentMaybe' "text"
+--   return $ RedText $ text \<|\> dfl
+--
+-- -- And this is how you use it.
+-- demo :: ('Hikaru.Action.MonadAction' m) => 'HtmlT' m ()
+-- demo = do
+--   'getWidget' @B5 \"demo.\" do
+--     redText (Just \"Hello, World!\")
+-- @
+--
+
+module Hikaru.Widget
+  (
+  -- * Using Widgets
+    newWidget
+  , getWidget
+  , postWidget
+
+  -- * Building Widgets
+  , WidgetT
+  , getArguments
+  , getArgumentMaybe
+  , getArgumentDefault
+  , getArgumentList
+
+  -- * Rendering Widgets
+  , Render(..)
+  )
+where
+  import Relude
+  import Data.Text (stripPrefix)
+  import Data.List (lookup)
+  import Lucid
+  import Hikaru.Types
+  import Hikaru.Action
+
+
+  -- |
+  -- Create new widget without GET or POST data.
+  --
+  newWidget :: forall t m a. (Render t m a, MonadAction m)
+            => Text -> WidgetT m a -> HtmlT m ()
+  newWidget prefix WidgetT{..} = do
+    widget <- lift $ runReaderT runWidgetT =<< newEnv prefix
+    render @t widget
+
+
+  -- |
+  -- Create the widget using GET query string parameters.
+  --
+  getWidget :: forall t m a. (Render t m a, MonadAction m)
+            => Text -> WidgetT m a -> HtmlT m ()
+  getWidget prefix WidgetT{..} = do
+    widget <- lift $ runReaderT runWidgetT =<< getEnv prefix
+    render @t widget
+
+
+  -- |
+  -- Create the widget using POST form fields.
+  --
+  postWidget :: forall t m a. (Render t m a, MonadAction m)
+             => Text -> WidgetT m a -> HtmlT m ()
+  postWidget prefix WidgetT{..} = do
+    widget <- lift $ runReaderT runWidgetT =<< postEnv prefix
+    render @t widget
+
+
+  -- |
+  -- Create widget environment without GET or POST data.
+  --
+  newEnv :: (MonadAction m) => Text -> m Env
+  newEnv prefix = do
+    args <- pure []
+    return Env{..}
+
+
+  -- |
+  -- Create widget environment from GET query string parameters.
+  --
+  getEnv :: (MonadAction m) => Text -> m Env
+  getEnv prefix = do
+    args <- mapMaybe (unprefix prefix) <$> getParams
+    return Env{..}
+
+
+  -- |
+  -- Create widget environment from POST form fields.
+  --
+  postEnv :: (MonadAction m) => Text -> m Env
+  postEnv prefix = do
+    args <- mapMaybe (unprefix prefix) <$> getFields
+    return Env{..}
+
+
+  -- |
+  -- Return only keys matching given prefix while stripping it.
+  --
+  unprefix :: Text -> (Text, v) -> Maybe (Text, v)
+  unprefix prefix (key, val) = (, val) <$> stripPrefix prefix key
+
+
+  -- |
+  -- Context for widget construction.
+  --
+  -- Makes it possible to access widget arguments in a way that is
+  -- portable across request methods and scoped, so that the same widget
+  -- type can be used multiple times on a single page.
+  --
+  newtype WidgetT m a
+    = WidgetT
+      { runWidgetT     :: ReaderT Env m a
+      }
+    deriving (Functor, Applicative, Monad, MonadIO, MonadTrans)
+
+  instance (MonadAction m) => MonadAction (WidgetT m) where
+    getActionEnv = lift getActionEnv
+
+
+  -- |
+  -- WidgetT environment.
+  --
+  data Env
+    = Env
+      { args           :: [(Text, Text)]
+      , prefix         :: Text
+      }
+
+
+  -- |
+  -- Obtain all widget arguments as pieces of text.
+  --
+  getArguments :: (Monad m) => WidgetT m [(Text, Text)]
+  getArguments = WidgetT do
+    Env{args} <- ask
+    return args
+
+
+  -- |
+  -- Obtain a specific widget argument and parse it on the fly.
+  -- Parsing failure maps to 'Nothing'.
+  --
+  getArgumentMaybe :: (Monad m, Param a) => Text -> WidgetT m (Maybe a)
+  getArgumentMaybe n = do
+    value <- lookup n <$> getArguments
+    return $ fromParam =<< value
+
+
+  -- |
+  -- Similar to 'getArgumentMaybe', but return either the parsed parameter
+  -- or the specified default value.
+  --
+  getArgumentDefault :: (Monad m, Param a) => Text -> a -> WidgetT m a
+  getArgumentDefault n v = fromMaybe v <$> getArgumentMaybe n
+
+
+  -- |
+  -- Obtain a group of request query string parameters with the same name
+  -- and parse them on the fly to the target type.
+  --
+  getArgumentList :: (Monad m, Param a) => Text -> WidgetT m [a]
+  getArgumentList n = mapMaybe (fromParam . snd)
+                   <$> filter ((n ==) . fst)
+                   <$> getArguments
+
+
+  -- |
+  -- Class to render widgets using various HTML/CSS frameworks.
+  --
+  -- Example:
+  --
+  -- @
+  -- -- For Bootstrap 5
+  -- data B5 = B5
+  --
+  -- data Green a = Green a
+  --
+  -- instance (Monad m) => 'Render' B5 m (Green Text) where
+  --   'render' (Green text) = do
+  --     span_ [style_ \"color: green\"] do
+  --       toHtml text
+  -- @
+  --
+  class (Monad m) => Render t m a where
+    -- |
+    -- Render widget as HTML.
+    --
+    render :: a -> HtmlT m ()
+
+
+-- vim:set ft=haskell sw=2 ts=2 et: