SwishButton
Button text slide and accent motion.
Live demo
01Clicked 0 times
Implementation
02<script>
import SwishButton from '$lib/components/SwishButton.svelte';
β
let count = $state(0);
</script>
β
<SwishButton text="Get started" onclick={() => count++} />SwishButton swaps a static label for a sliding text track on hover. The accent dot expands behind the arrow on the same timeline, so the motion always reads as one gesture. Native button semantics, focus ring, and disabled state all stay intact β extra Tailwind utilities can be passed via the class prop without touching the component source.
Logic explainer
03What Does It Do? (Plain English)
SwishButton is a call-to-action button that puts on a tiny three-act play whenever the cursor passes over it. The original label slides off to the left and fades, a duplicate label appears from the right with a contextual icon glued to it (an arrow for normal buttons, an X for disabled, a curved arrow for resets), and a tiny dot sitting in the corner blooms outwards into a fully-coloured background. All three layers run on the same 300ms timeline, so the eye reads it as a single coordinated swoosh rather than three separate animations.
It is intentionally a button and nothing more β native semantics are preserved end-to-end. Tab focus, keyboard activation, disabled states, form-submit semantics, and any extra HTML attributes you spread in all behave exactly as they would on a vanilla <button>. The drama is layered on top.
How It Works (Pseudo-Code)
on render:
resolve props: text, type, disabled, onclick, class, ...rest
emit <button class="group β¦" {onclick} {disabled} {type} {...rest}>
layer 1 β original label:
span: translate-x-1 (rest)
group-hover β translate-x-12 + opacity-0 (slides out left, fades)
layer 2 β incoming label + icon:
div absolutely positioned
translate-x-12 + opacity-0 (rest)
group-hover β -translate-x-1 + opacity-100 (slides in from right)
if disabled β render <X-icon SVG>
else if reset β render <undo-icon SVG>
else β render <arrow-icon SVG>
layer 3 β accent dot:
div: 8Γ8 px, positioned at (20%, 40%)
group-hover β snaps to (0%, 0%), 100%Γ100%, scale 1.8
(becomes the new background)
all three layers share:
transition-duration: 300ms
Tailwind `group-hover:` selector so one parent state drives all threeThe group Tailwind utility is the keystone here: it scopes the :hover state to the parent button and lets every child layer key off it. No JavaScript state, no event handlers wired to children β just declarative CSS via Tailwind's group variants.
The Core Concept: Three Layers, One Timeline
The illusion of a single coordinated motion comes from picking transform endpoints that all complete on the same beat. Each layer uses transition-all duration-300, so when the parent's :hover flips, the browser fires three transitions in lockstep:
t=0ms t=300ms
β β
βββ labelβ: x=4px, Ξ±=1 βββββββββΊ x=48px, Ξ±=0 (fade out left)
β
βββ labelβ: x=48px, Ξ±=0 βββββββββΊ x=-4px, Ξ±=1 (slide in from right)
β
βββ dot: 8Γ8 at (20%,40%), s=1 β100%Γ100% at (0,0), s=1.8 (background bloom)Two design decisions make the gesture feel deliberate rather than busy:
- Layer 2 starts where Layer 1 ends. Both labels travel through the same horizontal corridor. As one exits the right edge, the other arrives at it β the eye perceives a replacement, not two separate slides happening in parallel.
- The dot's expansion has nothing to do with the labels. It is a separate spatial gesture (corner β fill) so it doesn't compete for attention. It establishes the new background so the incoming label has somewhere to land.
The dot's scale-[1.8] overshoot is not vanity β at scale 1.0 a 100%Γ100% element exactly fills the button, but the rounded-lg radius of the dot would create a hairline gap at the corners. Scaling slightly past 100% pushes the dot's curved edge well outside the button's clipping radius, so the fill looks crisp.
CSS Animation Strategy
Everything is Tailwind utility classes β no custom keyframes, no JavaScript, no requestAnimationFrame. The component is small enough that you can read every transition in the markup itself.
<!-- layer 1 -->
<span class="translate-x-1 transition-all duration-300
group-hover:translate-x-12 group-hover:opacity-0">β¦</span>
<!-- layer 2 -->
<div class="absolute translate-x-12 opacity-0 transition-all duration-300
group-hover:-translate-x-1 group-hover:opacity-100">β¦</div>
<!-- layer 3 -->
<div class="absolute left-[20%] top-[40%] h-2 w-2 scale-[1] transition-all duration-300
group-hover:left-[0%] group-hover:top-[0%]
group-hover:h-full group-hover:w-full
group-hover:scale-[1.8]"></div>transition-all is normally an anti-pattern (it animates every changing property, including layout-triggers like width and height), but here it is deliberate: the dot's bloom requires width/height to animate, and Tailwind's atomic classes mean we don't pay for transitioning properties that aren't actually changing on the other layers.
Reduced motion is delegated to Tailwind's built-in motion-reduce: variant pattern. The component itself does not opt-in by default β consumers can layer overrides via the class prop if their design system expects automatic suppression.
State Flow Diagram
βββββββββββββββββββ
β REST STATE β
β labelβ visible β
β labelβ off-R β
β dot small β
ββββββββββ¬βββββββββ
β pointer enters
βΌ
βββββββββββββββββββ
β HOVER STATE β
β (300ms transit)β
β labelβ slides Lβ
β labelβ slides Lβ
β dot blooms β
ββββββββββ¬βββββββββ
β pointer leaves
βΌ
βββββββββββββββββββ
β REST STATE β
β (300ms reverse)β
βββββββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββ
β disabled prop = true β button blocks β
β pointer events; X-icon replaces arrow β
β in layer 2 should hover ever fire (e.g. β
β forced via class). β
ββββββββββββββββββββββββββββββββββββββββββββββProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
text |
string |
'Button' |
Label rendered in both layer 1 and layer 2. |
class |
string |
'' |
Extra Tailwind / custom classes appended to the button. Renamed from class because it is a JS reserved word. |
onclick |
(e: MouseEvent) => void |
β | Standard click handler. Spread directly onto the native button. |
disabled |
boolean |
false |
Native disabled. Also swaps the layer 2 icon to an X. |
type |
'button' | 'submit' | 'reset' |
'button' |
Native button type. reset swaps the icon to a curved-undo SVG. |
...rest |
Record<string, unknown> |
β | Any other native button attributes (form, name, value, aria-*) flow through. |
Edge Cases
| Situation | Behaviour |
|---|---|
disabled = true |
Hover transitions still fire if the host CSS allows them, but native pointer events are blocked so onclick will not run. The X-icon makes the unavailable state legible even when hovered. |
type = 'submit' inside a <form> |
Submits the form on click as a native button would. The animation does not interfere with submission timing β the click event propagates immediately. |
text empty string |
Both labels render empty. The dot bloom and arrow icon are still visible β the button still reads as a clickable surface. |
Custom class overrides width / colour |
The component uses cn() to merge classes, so user classes win deterministically over the defaults. Tailwind's atomic system keeps overrides predictable. |
Very long text (e.g. 30 characters) |
The w-32 default width clips the label. Override with the class prop (class="w-48") β the underlying transitions are unit-agnostic. |
| Touch devices | :hover activates on tap-and-hold but doesn't reliably fire on tap-release. Most platforms emit a brief synthetic hover; the animation either flashes or doesn't run. Fine for the use case (buttons still work) β don't rely on the swish to signal anything important. |
prefers-reduced-motion: reduce |
Not handled automatically. Wrap with consumer-side motion-reduce:transition-none in the class prop if your design language demands suppression. |
Dependencies
- Svelte 5 β
$props()rune;textis read directly from props rather than a snippet slot. - TailwindCSS β every animation utility (
group,transition-all,duration-300,group-hover:*) is Tailwind. Removing Tailwind would require either porting the utilities to scoped CSS or accepting a static button. $lib/utilsβcn()for safe class merging (aclsx+tailwind-mergestyle helper).$lib/typesβSwishButtonPropsinterface keeps prop shapes consistent across consumers.- No icon library β all three icons (arrow, X, undo) are inline SVG. Saves ~20β60 KB and avoids a font/CDN dependency.
File Structure
src/lib/components/SwishButton.svelte # implementation
src/lib/components/SwishButton.md # this explainer
src/routes/swishbutton/+page.svelte # demo page
src/lib/types.ts # SwishButtonProps interface
src/lib/utils.ts # cn() helperAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
text | string | 'Button' | Visible button label. |
class | string | '' | Additional Tailwind / utility classes appended to the button element. |
...rest | HTMLButtonAttributes | β | Native button props (onclick, disabled, type, etc.) flow through. |