<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sinny</title>
    <description>The latest articles on DEV Community by Sinny (@jlc488).</description>
    <link>https://dev.to/jlc488</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F165378%2Fbf53db20-faff-4d5f-a065-b555e9eaaf3d.jpg</url>
      <title>DEV Community: Sinny</title>
      <link>https://dev.to/jlc488</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jlc488"/>
    <language>en</language>
    <item>
      <title>Every web rich-text editor is missing a ruler</title>
      <dc:creator>Sinny</dc:creator>
      <pubDate>Sat, 08 Aug 2026 16:34:49 +0000</pubDate>
      <link>https://dev.to/jlc488/every-web-rich-text-editor-is-missing-a-ruler-10go</link>
      <guid>https://dev.to/jlc488/every-web-rich-text-editor-is-missing-a-ruler-10go</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://medium.com/@sinny78/every-web-rich-text-editor-is-missing-a-ruler-bb749d9ee0d3" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Word processors have had a horizontal ruler for forty years. You grab a marker, drag,&lt;br&gt;
and the paragraph's margins move. Every writer knows it without being taught.&lt;/p&gt;

&lt;p&gt;Open Froala, TinyMCE, CKEditor 5, or Quill and it isn't there. Not hidden in a menu —&lt;br&gt;
it doesn't exist. The &lt;a href="https://github.com/ckeditor/ckeditor5/issues/8086" rel="noopener noreferrer"&gt;CKEditor 5 feature request&lt;/a&gt;&lt;br&gt;
has been open since September 2020.&lt;/p&gt;

&lt;p&gt;The editors that &lt;em&gt;do&lt;/em&gt; ship a ruler — Syncfusion, ONLYOFFICE, DevExpress — are heavyweight&lt;br&gt;
document processors with their own document models. They can draw a ruler because they&lt;br&gt;
own the concept of a page. HTML doesn't have pages, and that is where most attempts stop.&lt;/p&gt;
&lt;h2&gt;
  
  
  What a ruler can actually mean in HTML
&lt;/h2&gt;

&lt;p&gt;The trick is to stop asking for a page and ask what the ruler &lt;em&gt;edits&lt;/em&gt;. Three CSS&lt;br&gt;
properties, and nothing else:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ruler control&lt;/th&gt;
&lt;th&gt;CSS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Left margin&lt;/td&gt;
&lt;td&gt;&lt;code&gt;margin-left&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Right margin&lt;/td&gt;
&lt;td&gt;&lt;code&gt;margin-right&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First-line indent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;text-indent&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That mapping is 1:1 and it is why the output travels. You get plain inline CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"margin-left: 75px; text-indent: 38px"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste that into an email, a CMS, another page — the layout holds, because there is no&lt;br&gt;
private document model to leave behind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tab stops are deliberately out of scope.&lt;/strong&gt; HTML has no tab-stop model. A ruler that&lt;br&gt;
pretends otherwise produces documents that break the moment they leave the editor.&lt;/p&gt;
&lt;h2&gt;
  
  
  editor-ruler
&lt;/h2&gt;

&lt;p&gt;An editor-agnostic core plus thin per-editor adapters. Apache-2.0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @devslab/editor-ruler            &lt;span class="c"&gt;# core, any contenteditable&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @devslab/editor-ruler-froala     &lt;span class="c"&gt;# Froala plugin&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @devslab/editor-ruler-tiptap     &lt;span class="c"&gt;# Tiptap v2/v3 extension&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @devslab/editor-ruler-ckeditor5  &lt;span class="c"&gt;# CKEditor 5 plugin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core has zero dependencies and also ships an iife build, so a plain &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag&lt;br&gt;
works with no bundler.&lt;/p&gt;

&lt;p&gt;What you get on every adapter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Left/right margin and first-line indent handles, hanging indent included&lt;/li&gt;
&lt;li&gt;A vertical ruler&lt;/li&gt;
&lt;li&gt;Guide lines with snapping — drag one out of the ruler, Photoshop-style. They are a
visual overlay and never appear in the exported HTML&lt;/li&gt;
&lt;li&gt;cm / inch / px, switchable at runtime&lt;/li&gt;
&lt;li&gt;One undo step per drag gesture, not one per pixel&lt;/li&gt;
&lt;li&gt;Handles are ARIA sliders, so the whole thing is keyboard-operable&lt;/li&gt;
&lt;li&gt;UI language follows the browser (ko/en built in)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whole-table indent and column-width markers are Froala-only for now; on Tiptap and&lt;br&gt;
CKEditor 5 you get everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three gotchas worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CSS ignores margins on table cells.&lt;/strong&gt; Setting &lt;code&gt;margin-left&lt;/code&gt; on a &lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt; does nothing,&lt;br&gt;
silently. The ruler has to notice the selection is inside a table and climb to the&lt;br&gt;
&lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; itself — which is also what Word does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tiptap's Image extension drops content without telling you.&lt;/strong&gt; &lt;code&gt;data:&lt;/code&gt; URIs need&lt;br&gt;
&lt;code&gt;allowBase64: true&lt;/code&gt;, and &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; inside &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; needs &lt;code&gt;inline: true&lt;/code&gt;. Nothing throws;&lt;br&gt;
the nodes just vanish on parse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Froala toolbar buttons ignore synthetic &lt;code&gt;click()&lt;/code&gt;.&lt;/strong&gt; They respond to a&lt;br&gt;
&lt;code&gt;mousedown&lt;/code&gt;/&lt;code&gt;mouseup&lt;/code&gt; sequence. Worth knowing before you write the automated test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Since this was published
&lt;/h2&gt;

&lt;p&gt;The library reached &lt;strong&gt;1.0 — the documented API is under semver now.&lt;/strong&gt; Also added:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The vertical ruler now works on all three adapters, not just Froala&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;verticalGutter&lt;/code&gt; reserves the strip's column up front, so toggling the vertical ruler
never reflows your content&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;visible: false&lt;/code&gt; starts the ruler hidden, with commands to bring it up&lt;/li&gt;
&lt;li&gt;The landing page serves English to non-Korean browsers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two of those came from a user reporting real problems — the strip sizing itself to the&lt;br&gt;
wrong element, and the content shifting when the vertical ruler appeared.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live demo&lt;/strong&gt; (tabs for Froala, Tiptap, CKEditor 5, and the bare core):
&lt;a href="https://devslab-kr.github.io/editor-ruler/" rel="noopener noreferrer"&gt;https://devslab-kr.github.io/editor-ruler/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/devslab-kr/editor-ruler" rel="noopener noreferrer"&gt;https://github.com/devslab-kr/editor-ruler&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;One-click StackBlitz sandboxes for each adapter are linked from the demo&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the editor you use isn't covered, an adapter only has to do three things: read the&lt;br&gt;
selected blocks, write styles, and hook into undo. Open an issue.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Recreating iOS's drum-roll picker in Vue 3 — inertia, rubber-banding, and the tap-vs-drag problem</title>
      <dc:creator>Sinny</dc:creator>
      <pubDate>Fri, 17 Jul 2026 00:53:58 +0000</pubDate>
      <link>https://dev.to/jlc488/recreating-ioss-drum-roll-picker-in-vue-3-inertia-rubber-banding-and-the-tap-vs-drag-problem-4269</link>
      <guid>https://dev.to/jlc488/recreating-ioss-drum-roll-picker-in-vue-3-inertia-rubber-banding-and-the-tap-vs-drag-problem-4269</guid>
      <description>&lt;p&gt;Every native iOS app gets the drum-roll date picker for free: flick a wheel, watch it coast with momentum, feel it bounce at the edges, and it always lands crisply on a value. On the web, you get &lt;code&gt;&amp;lt;input type="month"&amp;gt;&lt;/code&gt; — which Safari on desktop doesn't even render as a picker.&lt;/p&gt;

&lt;p&gt;I needed a month picker (not a full date picker — think subscription billing months, report periods, statements) for a Vue 3 app, and everything I found was either a full calendar with the day part amputated or looked nothing like what mobile users expect. So I built one, and this post is about the three pieces that were more interesting than expected: &lt;strong&gt;inertia scrolling&lt;/strong&gt;, &lt;strong&gt;rubber-band overscroll&lt;/strong&gt;, and &lt;strong&gt;distinguishing a tap from a drag&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The result is &lt;a href="https://github.com/jlc488/vue-month-spinner-picker" rel="noopener noreferrer"&gt;vue-month-spinner-picker&lt;/a&gt; — zero dependencies beyond Vue 3. Here's what we're building:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjfwyv8s7631cvedgdvkw.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjfwyv8s7631cvedgdvkw.gif" alt="vue-month-spinner-picker demo — bottom sheet opens and year/month drum-roll spinners scroll with inertia" width="390" height="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The core model: one offset, everything derived
&lt;/h2&gt;

&lt;p&gt;Each wheel is a plain &lt;code&gt;translateY&lt;/code&gt; transform driven by a single number, &lt;code&gt;currentOffset&lt;/code&gt;. The selected index is &lt;em&gt;derived&lt;/em&gt; from the offset, not stored separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;selectedIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;currentOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;itemHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one decision kills a whole class of sync bugs. There is no "the wheel shows March but state says April" — whatever the wheel physically shows &lt;em&gt;is&lt;/em&gt; the state. Snapping just means rounding the offset to the nearest multiple of &lt;code&gt;itemHeight&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One performance note: the drag bookkeeping (&lt;code&gt;startY&lt;/code&gt;, &lt;code&gt;lastY&lt;/code&gt;, &lt;code&gt;velocity&lt;/code&gt;, &lt;code&gt;isDragging&lt;/code&gt;…) is deliberately &lt;strong&gt;not&lt;/strong&gt; reactive. Only &lt;code&gt;currentOffset&lt;/code&gt; is a &lt;code&gt;ref&lt;/code&gt;, because it's the only thing the template needs. Touch-move fires a lot; you don't want Vue's reactivity churning on every intermediate value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inertia: measure velocity while dragging, spend it after release
&lt;/h2&gt;

&lt;p&gt;The iOS feel comes from the wheel &lt;em&gt;continuing&lt;/em&gt; after your finger leaves. That means during the drag you're not just moving the wheel — you're continuously measuring how fast the finger moves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onTouchMove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TouchEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;touches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// px per ~60fps frame&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Normalize to a frame, not a millisecond.&lt;/strong&gt; &lt;code&gt;(dy / dt) * 16&lt;/code&gt; gives you "pixels per 16ms frame", so the animation loop can just do &lt;code&gt;offset += velocity&lt;/code&gt; per &lt;code&gt;requestAnimationFrame&lt;/code&gt; tick without unit juggling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the last movement counts.&lt;/strong&gt; Velocity is recomputed on every move event, so a fast flick that &lt;em&gt;ends slow&lt;/em&gt; (finger decelerates before lifting) correctly produces little inertia. This is exactly how it feels on iOS — you can "catch" the wheel.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On release, if the velocity is above a small threshold, spend it with exponential friction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startInertia&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;friction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minVelocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;velocity&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="nx"&gt;friction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;velocity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;minVelocity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;snapToNearest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;currentOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;velocity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;animationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;animationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0.95&lt;/code&gt; per frame sounds gentle but decays fast: a flick at 40px/frame is down to ~5px/frame in 40 frames (~0.7s). When it drops below &lt;code&gt;minVelocity&lt;/code&gt;, we don't just stop — we &lt;strong&gt;snap to the nearest item&lt;/strong&gt;, so the wheel never rests between two values. Tuning these two constants &lt;em&gt;is&lt;/em&gt; the feel of the component; there's no way around trying values on a real phone.&lt;/p&gt;

&lt;p&gt;The other subtle bit: &lt;code&gt;touchstart&lt;/code&gt; calls &lt;code&gt;cancelAnimation()&lt;/code&gt; first. Grabbing a spinning wheel must stop it dead — if you forget this, the user fights a running rAF loop and the wheel stutters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rubber-banding: resistance is just multiplication
&lt;/h2&gt;

&lt;p&gt;iOS lets you drag past the ends, but with resistance, and it springs back. The resistance part turns out to be embarrassingly simple — past the boundary, movement counts at 30%:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyDragResistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maxOffset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minOffset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;itemHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;maxOffset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;maxOffset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;maxOffset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;minOffset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;minOffset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;minOffset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No spring physics during the drag — just a linear damp. The "spring back" is free too: when the finger lifts, &lt;code&gt;snapToNearest()&lt;/code&gt; clamps to a real index, and the CSS-transformed wheel animates home. During inertia, the boundary check is stricter: hit the edge while coasting and it clamps + snaps immediately rather than bouncing (month pickers don't need the full bounce — the resistance while dragging carries the tactile message).&lt;/p&gt;

&lt;h2&gt;
  
  
  The tap-vs-drag problem
&lt;/h2&gt;

&lt;p&gt;This is the one that produces real-world bug reports. Items on the wheel should be tappable (tap "October", get October). But a &lt;em&gt;drag&lt;/em&gt; that starts and ends on the same item also fires a &lt;code&gt;click&lt;/code&gt; on it. Without care, every flick ends with the wheel jumping to whatever item happened to be under the finger.&lt;/p&gt;

&lt;p&gt;The fix is a threshold plus one flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DRAG_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// px&lt;/span&gt;

&lt;span class="c1"&gt;// in move handler:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;DRAG_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;didDrag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// in click handler:&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onItemClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;didDrag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// this click is the tail end of a drag&lt;/span&gt;
  &lt;span class="c1"&gt;// ...select the item&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5px is the sweet spot I landed on: small enough that no intentional drag ever registers as a tap, large enough that a slightly-wobbly thumb press still counts as a tap. (Testing this deterministically required fake timers — a drag-then-click sequence is timing-dependent by nature.)&lt;/p&gt;

&lt;p&gt;Desktop needed its own variant of care: mouse dragging attaches &lt;code&gt;mousemove&lt;/code&gt;/&lt;code&gt;mouseup&lt;/code&gt; to &lt;code&gt;document&lt;/code&gt;, not the element — otherwise dragging fast enough to leave the element mid-drag leaves the wheel stuck to your cursor forever. And those document listeners must be removed in &lt;code&gt;onUnmounted&lt;/code&gt;, including the case where the component unmounts &lt;em&gt;mid-drag&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mouse wheel: debounce the snap, not the scroll
&lt;/h2&gt;

&lt;p&gt;Trackpads emit dozens of wheel events per gesture. Applying &lt;code&gt;deltaY&lt;/code&gt; directly feels right, but snapping after &lt;em&gt;each&lt;/em&gt; event makes the wheel vibrate. The trick is to let the offset run free and only snap when input goes quiet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onWheel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WheelEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;currentOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clampOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deltaY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wheelSnapTimer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;wheelSnapTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snapToNearest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;150ms after the last wheel event, snap. Scrolling feels continuous; the landing is still crisp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disabled items: snap must search outward
&lt;/h2&gt;

&lt;p&gt;Min/max month constraints mean some items on the wheel are visible but disabled (you can see January, but it's before &lt;code&gt;minMonth&lt;/code&gt;). Snapping to the &lt;em&gt;nearest&lt;/em&gt; item isn't enough — the nearest item might be disabled. So the snap searches outward for the nearest &lt;em&gt;enabled&lt;/em&gt; index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findNearestEnabledIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetIdx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;clamped&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flick hard into the disabled zone and the wheel coasts, hits it, and settles on the last valid month. No error state, no invalid selection possible — the physics enforce the constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Derive selection from the offset.&lt;/strong&gt; One source of truth for a physical UI element saves you from every sync bug at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The feel lives in ~4 constants&lt;/strong&gt; (friction, min velocity, resistance factor, drag threshold) — budget real device time for tuning them, not just DevTools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;click&lt;/code&gt; after a drag is not your click.&lt;/strong&gt; Handle it explicitly or it will handle you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;document&lt;/code&gt;-level listeners + component lifecycles&lt;/strong&gt; are where desktop drag implementations go to leak. Clean up in &lt;code&gt;onUnmounted&lt;/code&gt;, including mid-drag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing is ~300 lines in a composable (&lt;a href="https://github.com/jlc488/vue-month-spinner-picker/blob/main/src/composables/useSpinner.ts" rel="noopener noreferrer"&gt;&lt;code&gt;useSpinner.ts&lt;/code&gt;&lt;/a&gt;), tested with Vitest (114 tests, including property-based tests with fast-check for the value/format utilities).&lt;/p&gt;

&lt;p&gt;If you need a month picker — or just want to poke at the physics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📦 &lt;code&gt;npm install vue-month-spinner-picker&lt;/code&gt; (&lt;a href="https://www.npmjs.com/package/vue-month-spinner-picker" rel="noopener noreferrer"&gt;npm&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;📺 &lt;a href="https://jlc488.github.io/vue-month-spinner-picker/" rel="noopener noreferrer"&gt;Live demo&lt;/a&gt; · &lt;a href="https://stackblitz.com/github/jlc488/vue-month-spinner-picker/tree/main/examples/basic" rel="noopener noreferrer"&gt;StackBlitz playground&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;⭐ &lt;a href="https://github.com/jlc488/vue-month-spinner-picker" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; — issues and PRs welcome (i18n locales are an easy first contribution)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to answer questions about any of the physics/gesture handling in the comments.&lt;/p&gt;

</description>
      <category>vue</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
