diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..abab6d5
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "examples/RotaryEncoder/PropertyInspector/libs"]
+	path = examples/RotaryEncoder/PropertyInspector/libs
+	url = https://github.com/elgatosf/streamdeck-javascript-sdk.git
diff --git a/SharpDeck.sln b/SharpDeck.sln
index 8c1a94b..3e9bb9d 100644
--- a/SharpDeck.sln
+++ b/SharpDeck.sln
@@ -44,6 +44,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DynamicProfiles", "examples
 EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{FB8E473E-E823-47AA-B854-3490E26DF126}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DialCounter", "examples\RotaryEncoder\DialCounter.csproj", "{D8B1B1BE-0C7B-4DD5-9381-17B79AFCF211}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -70,6 +72,10 @@ Global
 		{C878D62A-22AD-4359-8A3F-17508423E46F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{C878D62A-22AD-4359-8A3F-17508423E46F}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{C878D62A-22AD-4359-8A3F-17508423E46F}.Release|Any CPU.Build.0 = Release|Any CPU
+		{D8B1B1BE-0C7B-4DD5-9381-17B79AFCF211}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D8B1B1BE-0C7B-4DD5-9381-17B79AFCF211}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{D8B1B1BE-0C7B-4DD5-9381-17B79AFCF211}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{D8B1B1BE-0C7B-4DD5-9381-17B79AFCF211}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -83,6 +89,7 @@ Global
 		{CD620C06-FD35-4A9E-936D-6B00C928ACB1} = {33FA25F3-1583-458E-B86C-80EE90C6EE38}
 		{7C66B2C5-F48E-4FF3-BE63-226F793163CC} = {33FA25F3-1583-458E-B86C-80EE90C6EE38}
 		{C878D62A-22AD-4359-8A3F-17508423E46F} = {91AE8732-D0CC-4618-968A-02EFDE46E4EF}
+		{D8B1B1BE-0C7B-4DD5-9381-17B79AFCF211} = {91AE8732-D0CC-4618-968A-02EFDE46E4EF}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {8CE7DE09-893D-4603-807D-674413F38D73}
diff --git a/examples/README.md b/examples/README.md
index 5a4f7a9..39928e3 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -2,6 +2,7 @@
 
 * [Counter](/examples/Counter) - Single action counter plugin.
 * [Shared Counter](/examples/SharedCounter) - Multiple action plugin with a shared counter and reset.
+* [Dial Counter](/examples/DialCounter) - Single action counter plugin using SD+ dial.
 
 ## How it works
 
diff --git a/examples/RotaryEncoder/Actions/CounterAction.cs b/examples/RotaryEncoder/Actions/CounterAction.cs
new file mode 100644
index 0000000..c643ea5
--- /dev/null
+++ b/examples/RotaryEncoder/Actions/CounterAction.cs
@@ -0,0 +1,113 @@
+namespace DialCounter.Actions;
+
+using Newtonsoft.Json.Linq;
+using SharpDeck;
+using SharpDeck.Events.Received;
+using SharpDeck.Layouts;
+using System.Threading.Tasks;
+
+/// <summary>
+/// The dial counter action; displays the count in A1 layout which
+/// - increases/decreases on rotating the dial
+/// - reset on pressing the dial
+/// - increases on pressing the LED screen
+/// - reset on holding the LED screen
+/// </summary>
+[StreamDeckAction("com.geekyeggo.dialcounter.counter")]
+public class CounterAction : StreamDeckAction<CounterSettings>
+{
+    /// <summary>
+    /// Occurs when <see cref="IStreamDeckConnection.WillAppear" /> is received for this instance.
+    /// </summary>
+    /// <param name="args">The <see cref="ActionEventArgs{T}" /> instance containing the event data.</param>
+    /// <returns>The task of handling the event.</returns>
+    protected override Task OnWillAppear(ActionEventArgs<AppearancePayload> args)
+    {
+        // get the current, and set the layout
+        var settings = args.Payload.GetSettings<CounterSettings>();
+        return this.UpdateCountAsync(settings.Count);
+    }
+
+    protected override async Task OnSendToPlugin(ActionEventArgs<JObject> args)
+    {
+        switch (args.Payload["sdpi_collection"]?["key"]?.ToString())
+        {
+            case "tickmultiplier":
+                if (int.TryParse(args.Payload["sdpi_collection"]?["value"]?.ToString(), out var value))
+                {
+                    // modify the multiplier
+                    var settings = await this.GetSettingsAsync<CounterSettings>();
+                    settings.TickMultiplier = value;
+
+                    // save the settings
+                    await this.SetSettingsAsync(settings);
+                }
+                break;
+        }
+        
+    }
+
+    /// <summary>
+    /// Occurs when <see cref="IStreamDeckConnection.DialRotate" /> is received for this instance.
+    /// </summary>
+    /// <param name="args">The <see cref="ActionEventArgs{T}" /> instance containing the event data.</param>
+    /// <returns>The task of handling the event.</returns>
+    protected override async Task OnDialRotate(ActionEventArgs<DialRotatePayload> args)
+    {
+        // modify the count
+        var settings = args.Payload.GetSettings<CounterSettings>();
+        settings.Count += args.Payload.Ticks * settings.TickMultiplier;
+
+        // save the settings, and set the layout
+        await this.SetSettingsAsync(settings);
+        await this.UpdateCountAsync(settings.Count);
+    }
+
+    /// <summary>
+    /// Occurs when <see cref="IStreamDeckConnection.DialPress" /> is received for this instance.
+    /// </summary>
+    /// <param name="args">The <see cref="ActionEventArgs{T}" /> instance containing the event data.</param>
+    /// <returns>The task of handling the event.</returns>
+    protected override async Task OnDialPress(ActionEventArgs<DialPayload> args)
+    {
+        // reset the count
+        var settings = args.Payload.GetSettings<CounterSettings>();
+        settings.Count = 0;
+
+        // save the settings, and set the layout
+        await this.SetSettingsAsync(settings);
+        await this.UpdateCountAsync(settings.Count);
+    }
+
+    /// <summary>
+    /// Occurs when <see cref="IStreamDeckConnection.TouchTap" /> is received for this instance.
+    /// </summary>
+    /// <param name="args">The <see cref="ActionEventArgs{T}" /> instance containing the event data.</param>
+    /// <returns>The task of handling the event.</returns>
+    protected override async Task OnTouchTap(ActionEventArgs<TouchTapPayload> args)
+    {
+        var settings = args.Payload.GetSettings<CounterSettings>();
+        if (args.Payload.Hold)
+        {
+            // reset the count
+            settings.Count = 0;
+        }
+        else
+        {
+            // increase the count
+            settings.Count += 1;
+        }
+
+        // save the settings, and set the layout
+        await this.SetSettingsAsync(settings);
+        await this.UpdateCountAsync(settings.Count);
+    }
+
+    private Task UpdateCountAsync(int count)
+    {
+        return this.SetFeedbackAsync(new LayoutA1()
+        {
+            Value = count.ToString()
+        });
+    }
+}
diff --git a/examples/RotaryEncoder/Actions/CounterSettings.cs b/examples/RotaryEncoder/Actions/CounterSettings.cs
new file mode 100644
index 0000000..f77b1d0
--- /dev/null
+++ b/examples/RotaryEncoder/Actions/CounterSettings.cs
@@ -0,0 +1,17 @@
+namespace DialCounter.Actions;
+
+/// <summary>
+/// The <see cref="CounterAction"/> settings.
+/// </summary>
+public class CounterSettings
+{
+    /// <summary>
+    /// Gets or sets the count.
+    /// </summary>
+    public int Count { get; set; } = 0;
+
+    /// <summary>
+    /// Gets or sets the tick multiplier
+    /// </summary>
+    public int TickMultiplier { get; set; } = 1;
+}
diff --git a/examples/RotaryEncoder/DialCounter.csproj b/examples/RotaryEncoder/DialCounter.csproj
new file mode 100644
index 0000000..43bdc3b
--- /dev/null
+++ b/examples/RotaryEncoder/DialCounter.csproj
@@ -0,0 +1,35 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net8.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+    <OutputPath>$(APPDATA)\Elgato\StreamDeck\Plugins\com.geekyeggo.dialcounter.sdPlugin\</OutputPath>
+    <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\..\src\SharpDeck\SharpDeck.csproj" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <None Update="Images\**\*.*">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="PropertyInspector\**\*.*">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="manifest.json">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+  </ItemGroup>
+
+  <Target Name="PreBuild" BeforeTargets="PreBuildEvent" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+    <Exec Command="taskkill -f -t -im StreamDeck.exe -fi &quot;status eq running&quot;" />
+  </Target>
+
+</Project>
diff --git a/examples/RotaryEncoder/Images/Counter/Icon.png b/examples/RotaryEncoder/Images/Counter/Icon.png
new file mode 100644
index 0000000..c77662e
Binary files /dev/null and b/examples/RotaryEncoder/Images/Counter/Icon.png differ
diff --git a/examples/RotaryEncoder/Images/Counter/Icon@2x.png b/examples/RotaryEncoder/Images/Counter/Icon@2x.png
new file mode 100644
index 0000000..a819c63
Binary files /dev/null and b/examples/RotaryEncoder/Images/Counter/Icon@2x.png differ
diff --git a/examples/RotaryEncoder/Images/Counter/Image.png b/examples/RotaryEncoder/Images/Counter/Image.png
new file mode 100644
index 0000000..721cd01
Binary files /dev/null and b/examples/RotaryEncoder/Images/Counter/Image.png differ
diff --git a/examples/RotaryEncoder/Images/Counter/Image@2x.png b/examples/RotaryEncoder/Images/Counter/Image@2x.png
new file mode 100644
index 0000000..4ca98e8
Binary files /dev/null and b/examples/RotaryEncoder/Images/Counter/Image@2x.png differ
diff --git a/examples/RotaryEncoder/Images/Plugin/CategoryIcon.png b/examples/RotaryEncoder/Images/Plugin/CategoryIcon.png
new file mode 100644
index 0000000..c9faaa2
Binary files /dev/null and b/examples/RotaryEncoder/Images/Plugin/CategoryIcon.png differ
diff --git a/examples/RotaryEncoder/Images/Plugin/CategoryIcon@2x.png b/examples/RotaryEncoder/Images/Plugin/CategoryIcon@2x.png
new file mode 100644
index 0000000..cae11dc
Binary files /dev/null and b/examples/RotaryEncoder/Images/Plugin/CategoryIcon@2x.png differ
diff --git a/examples/RotaryEncoder/Images/Plugin/Icon.png b/examples/RotaryEncoder/Images/Plugin/Icon.png
new file mode 100644
index 0000000..13e1f76
Binary files /dev/null and b/examples/RotaryEncoder/Images/Plugin/Icon.png differ
diff --git a/examples/RotaryEncoder/Images/Plugin/Icon@2x.png b/examples/RotaryEncoder/Images/Plugin/Icon@2x.png
new file mode 100644
index 0000000..9afe4ee
Binary files /dev/null and b/examples/RotaryEncoder/Images/Plugin/Icon@2x.png differ
diff --git a/examples/RotaryEncoder/Program.cs b/examples/RotaryEncoder/Program.cs
new file mode 100644
index 0000000..41e5f36
--- /dev/null
+++ b/examples/RotaryEncoder/Program.cs
@@ -0,0 +1,4 @@
+#if DEBUG
+System.Diagnostics.Debugger.Launch();
+#endif
+SharpDeck.StreamDeckPlugin.Run();
diff --git a/examples/RotaryEncoder/PropertyInspector/counter.html b/examples/RotaryEncoder/PropertyInspector/counter.html
new file mode 100644
index 0000000..a9005d1
--- /dev/null
+++ b/examples/RotaryEncoder/PropertyInspector/counter.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+
+<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
+<head>
+    <meta charset="utf-8" />
+    <title>Dial Counter Property Inspector</title>
+    <link rel="stylesheet" href="./libs/css/sdpi.css">
+</head>
+<body>
+    <div class="sdpi-wrapper">
+        <div type="range" class="sdpi-item" id="tickmultiplier">
+            <div class="sdpi-item-label">Tick Multiplier</div>
+            <div class="sdpi-item-value">
+                <span class="clickable" value="-10">-10</span>
+                <input class="floating-tooltip" type="range" min="-10" max="10" value=1>
+                <span class="clickable" value="10">10</span>
+            </div>
+        </div>
+    </div>
+
+    <div class="sdpi-info-label hidden" style="top: -1000;" value=""></div>
+
+    <script src="./libs/js/constants.js"></script>
+    <script src="./libs/js/events.js"></script>
+    <script src="./libs/js/api.js"></script>
+    <script src="./libs/js/property-inspector.js"></script>
+    <script src="./libs/js/dynamic-styles.js"></script>
+    <script src="./counter.js"></script>
+</body>
+</html>
diff --git a/examples/RotaryEncoder/PropertyInspector/counter.js b/examples/RotaryEncoder/PropertyInspector/counter.js
new file mode 100644
index 0000000..1d855ac
--- /dev/null
+++ b/examples/RotaryEncoder/PropertyInspector/counter.js
@@ -0,0 +1,298 @@
+/**
+ * Copied and simplified from PI Sample
+ * https://github.com/elgatosf/streamdeck-pisamples/blob/master/Sources/com.elgato.pisamples.sdPlugin/js/index_pi.js
+ **/
+
+/// <reference path="./libs/js/property-inspector.js" />
+/// <reference path="./libs/js/action.js" />
+/// <reference path="./libs/js/utils.js" />
+
+console.log('Property Inspector loaded', $PI);
+
+// register a callback for the 'connected' event
+// this is all you need to communicate with the plugin and the StreamDeck software
+$PI.onConnected(jsn => {
+    console.log('Property Inspector connected', jsn);
+    initPropertyInspector();
+    console.log(jsn.actionInfo.payload.settings);
+
+    // Initialize the slider with current value in settings
+    document.getElementById('tickmultiplier').getElementsByTagName("input")[0].value = jsn.actionInfo.payload.settings['tickMultiplier'];
+});
+
+/**
+ * DEMO
+ * ----
+ * 
+ * This initializes all elements found in the PI and makes them
+ * interactive. It will also send the values to the plugin.
+ * 
+ * */
+
+function initPropertyInspector(initDelay) {
+    prepareDOMElements(document);
+    /** expermimental carousel is not part of the DOM
+     * so let the DOM get constructed first and then
+     * inject the carousel */
+    setTimeout(function () {
+        initToolTips();
+    }, initDelay || 100);
+}
+
+// our method to pass values to the plugin
+function sendValueToPlugin(value, param) {
+    $PI.sendToPlugin({ [param]: value });
+}
+
+/** CREATE INTERACTIVE HTML-DOM
+ * where elements can be clicked or act on their 'change' event.
+ * Messages are then processed using the 'handleSdpiItemChange' method below.
+ */
+function prepareDOMElements(baseElement) {
+    const onchangeevt = 'onchange'; // 'oninput'; // change this, if you want interactive elements act on any change, or while they're modified
+
+    baseElement = baseElement || document;
+    Array.from(baseElement.querySelectorAll('.sdpi-item-value')).forEach(
+        (el, i) => {
+            const elementsToClick = [
+                'BUTTON',
+                'OL',
+                'UL',
+                'TABLE',
+                'METER',
+                'PROGRESS',
+                'CANVAS',
+                'DATALIST'
+            ].includes(el.tagName);
+            const evt = elementsToClick ? 'onclick' : onchangeevt || 'onchange';
+
+            /** Look for <input><span> combinations, where we consider the span as label for the input
+             * we don't use `labels` for that, because a range could have 2 labels.
+             */
+            const inputGroup = el.querySelectorAll('input + span');
+            if (inputGroup.length === 2) {
+                const offs = inputGroup[0].tagName === 'INPUT' ? 1 : 0;
+                inputGroup[offs].innerText = inputGroup[1 - offs].value;
+                inputGroup[1 - offs]['oninput'] = function () {
+                    inputGroup[offs].innerText = inputGroup[1 - offs].value;
+                };
+            }
+            /** We look for elements which have an 'clickable' attribute
+             * we use these e.g. on an 'inputGroup' (<span><input type="range"><span>) to adjust the value of
+             * the corresponding range-control
+             */
+            Array.from(el.querySelectorAll('.clickable')).forEach(
+                (subel, subi) => {
+                    subel['onclick'] = function (e) {
+                        handleSdpiItemChange(e.target, subi);
+                    };
+                }
+            );
+            /** Just in case the found HTML element already has an input or change - event attached, 
+             * we clone it, and call it in the callback, right before the freshly attached event
+            */
+            const cloneEvt = el[evt];
+            el[evt] = function (e) {
+                if (cloneEvt) cloneEvt();
+                handleSdpiItemChange(e.target, i);
+            };
+        }
+    );
+
+    /**
+     * You could add a 'label' to a textares, e.g. to show the number of charactes already typed
+     * or contained in the textarea. This helper updates this label for you.
+     */
+    baseElement.querySelectorAll('textarea').forEach((e) => {
+        const maxl = e.getAttribute('maxlength');
+        e.targets = baseElement.querySelectorAll(`[for='${e.id}']`);
+        if (e.targets.length) {
+            let fn = () => {
+                for (let x of e.targets) {
+                    x.textContent = maxl ? `${e.value.length}/${maxl}` : `${e.value.length}`;
+                }
+            };
+            fn();
+            e.onkeyup = fn;
+        }
+    });
+
+    baseElement.querySelectorAll('[data-open-url]').forEach(e => {
+        const value = e.getAttribute('data-open-url');
+        if (value) {
+            e.onclick = () => {
+                let path;
+                if (value.indexOf('http') !== 0) {
+                    path = document.location.href.split('/');
+                    path.pop();
+                    path.push(value.split('/').pop());
+                    path = path.join('/');
+                } else {
+                    path = value;
+                }
+                $SD.api.openUrl($SD.uuid, path);
+            };
+        } else {
+            console.log(`${value} is not a supported url`);
+        }
+    });
+}
+
+function handleSdpiItemChange(e, idx) {
+
+    /** Following items are containers, so we won't handle clicks on them */
+
+    if (['OL', 'UL', 'TABLE'].includes(e.tagName)) {
+        return;
+    }
+
+    /** SPANS are used inside a control as 'labels'
+     * If a SPAN element calls this function, it has a class of 'clickable' set and is thereby handled as
+     * clickable label.
+     */
+
+    if (e.tagName === 'SPAN' || e.tagName === 'OPTION') {
+        const inp = e.tagName === 'OPTION' ? e.closest('.sdpi-item-value')?.querySelector('input') : e.parentNode.querySelector('input');
+        var tmpValue;
+
+        // if there's no attribute set for the span, try to see, if there's a value in the textContent
+        // and use it as value
+        if (!e.hasAttribute('value')) {
+            tmpValue = Number(e.textContent);
+            if (typeof tmpValue === 'number' && tmpValue !== null) {
+                e.setAttribute('value', 0 + tmpValue); // this is ugly, but setting a value of 0 on a span doesn't do anything
+                e.value = tmpValue;
+            }
+        } else {
+            tmpValue = Number(e.getAttribute('value'));
+        }
+        console.log("clicked!!!!", e, inp, tmpValue, e.closest('.sdpi-item-value'), e.closest('input'));
+
+        if (inp && tmpValue !== undefined) {
+            inp.value = tmpValue;
+        } else return;
+    }
+
+    const selectedElements = [];
+    const isList = ['LI', 'OL', 'UL', 'DL', 'TD'].includes(e.tagName);
+    const sdpiItem = e.closest('.sdpi-item');
+    const sdpiItemGroup = e.closest('.sdpi-item-group');
+    let sdpiItemChildren = isList
+        ? sdpiItem.querySelectorAll(e.tagName === 'LI' ? 'li' : 'td')
+        : sdpiItem.querySelectorAll('.sdpi-item-child > input');
+
+    if (isList) {
+        const siv = e.closest('.sdpi-item-value');
+        if (!siv.classList.contains('multi-select')) {
+            for (let x of sdpiItemChildren) x.classList.remove('selected');
+        }
+        if (!siv.classList.contains('no-select')) {
+            e.classList.toggle('selected');
+        }
+    }
+
+    if (sdpiItemChildren.length && ['radio', 'checkbox'].includes(sdpiItemChildren[0].type)) {
+        e.setAttribute('_value', e.checked); //'_value' has priority over .value
+    }
+    if (sdpiItemGroup && !sdpiItemChildren.length) {
+        for (let x of ['input', 'meter', 'progress']) {
+            sdpiItemChildren = sdpiItemGroup.querySelectorAll(x);
+            if (sdpiItemChildren.length) break;
+        }
+    }
+
+    if (e.selectedIndex !== undefined) {
+        if (e.tagName === 'SELECT') {
+            sdpiItemChildren.forEach((ec, i) => {
+                selectedElements.push({ [ec.id]: ec.value });
+            });
+        }
+        idx = e.selectedIndex;
+    } else {
+        sdpiItemChildren.forEach((ec, i) => {
+            if (ec.classList.contains('selected')) {
+                selectedElements.push(ec.textContent);
+            }
+            if (ec === e) {
+                idx = i;
+                selectedElements.push(ec.value);
+            }
+        });
+    }
+
+    const returnValue = {
+        key: e.id && e.id.charAt(0) !== '_' ? e.id : sdpiItem.id,
+        value: isList
+            ? e.textContent
+            : e.hasAttribute('_value')
+                ? e.getAttribute('_value')
+                : e.value
+                    ? e.type === 'file'
+                        ? decodeURIComponent(e.value.replace(/^C:\\fakepath\\/, ''))
+                        : e.value
+                    : e.getAttribute('value'),
+        group: sdpiItemGroup ? sdpiItemGroup.id : false,
+        index: idx,
+        selection: selectedElements,
+        checked: e.checked
+    };
+
+    /** Just simulate the original file-selector:
+     * If there's an element of class '.sdpi-file-info'
+     * show the filename there
+     */
+    if (e.type === 'file') {
+        const info = sdpiItem.querySelector('.sdpi-file-info');
+        if (info) {
+            const s = returnValue.value.split('/').pop();
+            info.textContent = s.length > 28
+                ? s.substr(0, 10)
+                + '...'
+                + s.substr(s.length - 10, s.length)
+                : s;
+        }
+    }
+
+    sendValueToPlugin(returnValue, 'sdpi_collection');
+}
+function rangeToPercent(value, min, max) {
+    return (value - min) / (max - min);
+};
+
+function initToolTips() {
+    const tooltip = document.querySelector('.sdpi-info-label');
+    const arrElements = document.querySelectorAll('.floating-tooltip');
+    arrElements.forEach((e, i) => {
+        initToolTip(e, tooltip);
+    });
+}
+
+function initToolTip(element, tooltip) {
+
+    const tw = tooltip.getBoundingClientRect().width;
+    const suffix = element.getAttribute('data-suffix') || '';
+
+    const fn = () => {
+        const elementRect = element.getBoundingClientRect();
+        const w = elementRect.width - tw / 2;
+        const percnt = rangeToPercent(element.value, element.min, element.max);
+        tooltip.textContent = suffix != "" ? `${element.value} ${suffix}` : String(element.value);
+        tooltip.style.left = `${elementRect.left + Math.round(w * percnt) - tw / 4}px`;
+        tooltip.style.top = `${elementRect.top + 20}px`;
+    };
+
+    if (element) {
+        element.addEventListener('mouseenter', function () {
+            tooltip.classList.remove('hidden');
+            tooltip.classList.add('shown');
+            fn();
+        }, false);
+
+        element.addEventListener('mouseout', function () {
+            tooltip.classList.remove('shown');
+            tooltip.classList.add('hidden');
+            fn();
+        }, false);
+        element.addEventListener('input', fn, false);
+    }
+}
diff --git a/examples/RotaryEncoder/manifest.json b/examples/RotaryEncoder/manifest.json
new file mode 100644
index 0000000..177bb07
--- /dev/null
+++ b/examples/RotaryEncoder/manifest.json
@@ -0,0 +1,47 @@
+{
+  "$schema": "https://raw.githubusercontent.com/GeekyEggo/SharpDeck/master/src/SharpDeck.PropertyInspector/manifest-schema.json",
+  "Name": "Dial Counter",
+  "Version": "1.0.0",
+  "Author": "Hy",
+  "Actions": [
+    {
+      "Name": "Dial Counter",
+      "UUID": "com.geekyeggo.dialcounter.counter",
+      "Icon": "Images/Counter/Icon",
+      "Tooltip": "A counter action with SD+ dial",
+      "States": [
+        {
+          "FontSize": "18",
+          "Image": "Images/Counter/Image",
+          "TitleAlignment": "middle"
+        }
+      ],
+      "PropertyInspectorPath": "PropertyInspector/counter.html",
+      "Controllers": [ "Encoder" ],
+      "Encoder": {
+        "layout": "$A1",
+        "TriggerDescription": {
+          "Rotate": "Change counter value",
+          "Push": "Reset the counter",
+          "Touch": "Increase the counter",
+          "LongTouch": "Reset the counter"
+        }
+      }
+    }
+  ],
+  "Category": "Dial Counter",
+  "CategoryIcon": "Images/Plugin/CategoryIcon",
+  "CodePath": "DialCounter.exe",
+  "Description": "A dial counter plugin example",
+  "Icon": "Images/Plugin/Icon",
+  "SDKVersion": 2,
+  "Software": {
+    "MinimumVersion": "6.0"
+  },
+  "OS": [
+    {
+      "MinimumVersion": "10",
+      "Platform": "windows"
+    }
+  ]
+}