shadimage
uselayouts
componentslide-subscribesource: ok · hit

Slide Subscribe

Pick a billing period, then slide to start a trial.

npx shadcn add "https://uselayouts.com/r/slide-subscribe.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Files3 · source cached 10/8/2026, 1:41:25 PM

registry/default/example/slide-subscribe.tsx
"use client"

import React, { useState } from "react"
import { motion, AnimatePresence, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import {
  ArrowRight01Icon,
  Tick02Icon,
  ArrowRight02Icon,
} from "@hugeicons/core-free-icons"
import {
  SlideToUnlock,
  SlideToUnlockHandle,
  SlideToUnlockText,
  SlideToUnlockTrack,
} from "./slide-subscribe-unlock"
import { ShimmeringText } from "./slide-subscribe-shimmer"

interface PricingOption {
  id: string
  title: string
  price: string
  trial: string
  yearlyPrice?: string
}

const pricingOptions: PricingOption[] = [
  {
    id: "pro-yearly",
    title: "Annual",
    price: "$9.99/mo",
    trial: "14-day free trial",
    yearlyPrice: "$89.98/yr",
  },
  {
    id: "pro-monthly",
    title: "Monthly",
    price: "$14.99/mo",
    trial: "7-day free trial",
  },
  {
    id: "enterprise",
    title: "Enterprise",
    price: "Custom",
    trial: "Contact for pricing",
  },
]

const easeOut = [0.32, 0.72, 0, 1] as const

export default function SlideSubscribe() {
  const [selected, setSelected] = useState<string>("pro-yearly")
  const [isUnlocked, setIsUnlocked] = useState(false)
  const reduce = useReducedMotion() ?? false

  return (
    <div className="w-full max-w-[380px] space-y-4">
      <div className="relative flex h-[302px] flex-col overflow-hidden rounded-2xl bg-muted/60 p-3 shadow-inner">
        <AnimatePresence mode="wait" initial={false}>
          {!isUnlocked ? (
            <motion.div
              key="pricing-list"
              className="flex h-full flex-1 flex-col"
              exit={
                reduce
                  ? undefined
                  : {
                      opacity: 0,
                      y: -8,
                      filter: "blur(2px)",
                      transition: { duration: 0.14, ease: "easeIn" },
                    }
              }
            >
              <div className="relative flex-1 space-y-2">
                {pricingOptions.map((option) => (
                  <PricingCard
                    key={option.id}
                    option={option}
                    isSelected={selected === option.id}
                    onSelect={() => setSelected(option.id)}
                  />
                ))}
              </div>

              <div className="pt-6 pb-2">
                <p className="text-center text-xs font-medium text-muted-foreground">
                  Risk-free trial. Cancel anytime with one click.
                </p>
              </div>
            </motion.div>
          ) : (
            <motion.div
              key="success-message"
              className="flex h-full flex-1 flex-col items-center justify-center p-6 text-center"
              initial={reduce ? false : { opacity: 0, y: 8, filter: "blur(2px)" }}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              transition={{ duration: 0.22, ease: easeOut }}
            >
              <motion.div
                initial={
                  reduce ? false : { scale: 0.3, opacity: 0, filter: "blur(3px)" }
                }
                animate={{ scale: 1, opacity: 1, filter: "blur(0px)" }}
                transition={{ type: "spring", duration: 0.35, bounce: 0 }}
                className="mb-6 flex size-16 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg will-change-transform"
              >
                <HugeiconsIcon
                  icon={Tick02Icon}
                  className="size-8 stroke-[3]"
                />
              </motion.div>
              <div className="space-y-2">
                <motion.h3
                  initial={reduce ? false : { opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ duration: 0.2, delay: reduce ? 0 : 0.08, ease: easeOut }}
                  className="text-xl font-medium tracking-tight text-balance text-foreground"
                >
                  Subscription Active
                </motion.h3>
                <motion.p
                  initial={reduce ? false : { opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ duration: 0.2, delay: reduce ? 0 : 0.16, ease: easeOut }}
                  className="text-sm text-pretty text-muted-foreground"
                >
                  Your Pro trial has started. Check your email for next steps.
                </motion.p>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      <div className="h-14">
        <AnimatePresence mode="wait" initial={false}>
          {!isUnlocked ? (
            <motion.div
              key="slider"
              exit={
                reduce
                  ? undefined
                  : {
                      opacity: 0,
                      y: -8,
                      filter: "blur(2px)",
                      transition: { duration: 0.14, ease: "easeIn" },
                    }
              }
            >
              <SlideToUnlock
                handleWidth={56}
                onUnlock={() => setIsUnlocked(true)}
                className="w-full overflow-hidden rounded-2xl bg-primary p-1 shadow-lg ring-0"
              >
                <SlideToUnlockTrack className="relative h-12">
                  <SlideToUnlockHandle
                    aria-label="Slide to start trial"
                    className="z-20 h-12 w-14 rounded-xl bg-primary-foreground text-primary shadow-md"
                  >
                    <HugeiconsIcon
                      icon={ArrowRight01Icon}
                      className="size-6"
                    />
                  </SlideToUnlockHandle>

                  <SlideToUnlockText className="z-10 flex items-center justify-center pr-4 text-base text-primary-foreground">
                    {({ isDragging }) => (
                      <ShimmeringText
                        text={
                          isDragging
                            ? "Release to confirm"
                            : "Slide to start trial"
                        }
                        isStopped={isDragging}
                        className="font-medium [--color:color-mix(in_oklab,var(--primary-foreground)_42%,transparent)] [--shimmering-color:var(--primary-foreground)]"
                      />
                    )}
                  </SlideToUnlockText>
                </SlideToUnlockTrack>
              </SlideToUnlock>
            </motion.div>
          ) : (
            <motion.button
              key="dashboard-button"
              type="button"
              initial={reduce ? false : { opacity: 0, y: 8, scale: 0.97 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              whileTap={reduce ? undefined : { scale: 0.96 }}
              transition={{ duration: 0.2, ease: easeOut }}
              onClick={() => setIsUnlocked(false)}
              className={cn(
                "group flex h-14 w-full cursor-pointer items-center justify-center gap-2 rounded-2xl font-medium shadow-md",
                "bg-primary text-primary-foreground",
              )}
            >
              <span>Back to Dashboard</span>
              <HugeiconsIcon
                icon={ArrowRight02Icon}
                className="size-5 transition-transform duration-150 ease-out group-hover:translate-x-0.5"
              />
            </motion.button>
          )}
        </AnimatePresence>
      </div>
    </div>
  )
}

function PricingCard({
  option,
  isSelected,
  onSelect,
}: {
  option: PricingOption
  isSelected: boolean
  onSelect: () => void
}) {
  return (
    <button
      onClick={onSelect}
      className={cn(
        "group relative flex w-full cursor-pointer items-center gap-3 rounded-xl bg-card p-4 text-left transition-all duration-300",
        isSelected
          ? "shadow-[0_4px_20px_-4px_rgba(0,0,0,0.1)]"
          : "hover:bg-card/60"
      )}
    >
      {/* Moving Stroke / Highlight */}
      <AnimatePresence>
        {isSelected && (
          <motion.div
            layoutId="pricing-stroke"
            className="pointer-events-none absolute -inset-[1px] z-50 rounded-xl border-[2px] border-primary"
            initial={false}
            transition={{
              type: "spring",
              duration: 0.6,
              bounce: 0.1,
            }}
          />
        )}
      </AnimatePresence>

      {/* Checkmark Circle */}
      <div
        className={cn(
          "relative z-10 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-500 ease-out",
          isSelected
            ? "border-primary bg-primary text-primary-foreground"
            : "border-input bg-muted"
        )}
      >
        <AnimatePresence mode="popLayout">
          {isSelected ? (
            <motion.div
              key="checked"
              initial={{ scale: 0.5, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              exit={{ scale: 0.5, opacity: 0 }}
              transition={{ type: "spring" }}
            >
              <HugeiconsIcon
                icon={Tick02Icon}
                className="h-5 w-5 stroke-[4] will-change-transform"
              />
            </motion.div>
          ) : (
            <motion.div
              key="unchecked"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              className="h-4 w-4"
            />
          )}
        </AnimatePresence>
      </div>

      <div className="relative z-10 flex-1">
        <div className="mb-0 flex items-baseline justify-between">
          <h3
            className={cn(
              "text-lg font-medium tracking-tight transition-colors duration-300",
              "font-serif",
              isSelected ? "text-foreground" : "text-foreground/90"
            )}
          >
            {option.title}
          </h3>
          <div className="text-right">
            <span className="text-base font-medium tracking-tight text-foreground">
              {option.price}
            </span>
          </div>
        </div>
        <div className="flex items-center justify-between">
          <p className="text-xs font-normal text-muted-foreground">
            {option.trial}
          </p>
          {option.yearlyPrice && (
            <p className="text-xs font-normal text-muted-foreground">
              {option.yearlyPrice}
            </p>
          )}
        </div>
      </div>
    </button>
  )
}
registry/default/example/slide-subscribe-unlock.tsx
"use client"

import {
  animate,
  motion,
  type MotionValue,
  useMotionValue,
  useTransform,
} from "motion/react"
import type { ComponentProps, ComponentPropsWithoutRef, JSX } from "react"
import { createContext, useCallback, useContext, useRef, useState } from "react"

import { cn } from "@/lib/utils"

type SlideToUnlockContextValue = {
  x: MotionValue<number>
  trackRef: React.RefObject<HTMLDivElement | null>
  isDragging: boolean
  handleWidth: number
  textOpacity: MotionValue<number>
  onDragStart: () => void
  onDragEnd: () => void
}

const SlideToUnlockContext = createContext<SlideToUnlockContextValue | null>(
  null
)

function useSlideToUnlock() {
  const context = useContext(SlideToUnlockContext)
  if (!context) {
    throw new Error(
      `SlideToUnlock components must be used within SlideToUnlock`
    )
  }
  return context
}

export type SlideToUnlockRootProps = ComponentProps<"div"> & {
  /**
   * Width of the drag handle in pixels.
   * @defaultValue 56
   * */
  handleWidth?: number
  /** Called when the handle is dragged fully to the end. */
  onUnlock?: () => void
}

export function SlideToUnlock({
  className,
  handleWidth = 56,
  children,
  onUnlock,
  ...props
}: SlideToUnlockRootProps) {
  const trackRef = useRef<HTMLDivElement>(null)
  const [isDragging, setIsDragging] = useState(false)
  const x = useMotionValue(0)

  const fadeDistance = handleWidth
  const textOpacity = useTransform(x, [0, fadeDistance], [1, 0])

  const handleDragStart = useCallback(() => {
    setIsDragging(true)
  }, [])

  const handleDragEnd = useCallback(() => {
    setIsDragging(false)

    const trackWidth = trackRef.current?.offsetWidth || 0
    const maxX = trackWidth - handleWidth

    if (x.get() >= maxX) {
      onUnlock?.()
    } else {
      animate(x, 0, { type: "spring", bounce: 0, duration: 0.25 })
    }
  }, [x, onUnlock, handleWidth])

  return (
    <SlideToUnlockContext.Provider
      value={{
        x,
        trackRef,
        isDragging,
        handleWidth,
        textOpacity,
        onDragStart: handleDragStart,
        onDragEnd: handleDragEnd,
      }}
    >
      <div
        data-slot="slide-to-unlock"
        className={cn(
          "w-54 rounded-xl bg-muted p-1 shadow-inner ring-1 ring-foreground/10 ring-inset",
          className
        )}
        {...props}
      >
        {children}
      </div>
    </SlideToUnlockContext.Provider>
  )
}

export type SlideToUnlockTrackProps = ComponentProps<"div">

export function SlideToUnlockTrack({
  className,
  children,
  ...props
}: SlideToUnlockTrackProps) {
  const { trackRef } = useSlideToUnlock()

  return (
    <div
      ref={trackRef}
      data-slot="track"
      className={cn(
        "relative flex h-10 items-center justify-center",
        className
      )}
      {...props}
    >
      {children}
    </div>
  )
}

export type SlideToUnlockTextProps = Omit<
  ComponentPropsWithoutRef<typeof motion.div>,
  "children"
> & {
  /**
   * Accepts a render function as `children` to react to the dragging state.
   *
   * @example
   * ```tsx
   * <SlideToUnlockText>
   *   {({ isDragging }) => <span>{isDragging ? "Release..." : "Slide to unlock"}</span>}
   * </SlideToUnlockText>
   * ```
   */
  children: JSX.Element | ((props: { isDragging: boolean }) => JSX.Element)
}

export function SlideToUnlockText({
  className,
  children,
  style,
  ...props
}: SlideToUnlockTextProps) {
  const { handleWidth, textOpacity, isDragging } = useSlideToUnlock()

  return (
    <motion.div
      data-slot="text"
      data-dragging={isDragging}
      className={cn("pl-1 text-lg font-medium", className)}
      style={{ marginLeft: handleWidth, opacity: textOpacity, ...style }}
      {...props}
    >
      {typeof children === "function" ? children({ isDragging }) : children}
    </motion.div>
  )
}

export type SlideToUnlockHandleProps = ComponentPropsWithoutRef<
  typeof motion.div
>

export function SlideToUnlockHandle({
  className,
  children,
  style,
  ...props
}: SlideToUnlockHandleProps) {
  const {
    x,
    trackRef,
    onDragStart,
    onDragEnd,
    handleWidth: width,
  } = useSlideToUnlock()

  return (
    <motion.div
      data-slot="handle"
      role="slider"
      aria-valuemin={0}
      aria-valuemax={100}
      className={cn(
        "absolute top-0 left-0 flex h-10 cursor-grab items-center justify-center rounded-lg bg-background text-muted-foreground shadow-sm active:cursor-grabbing",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
        className
      )}
      style={{ width, x, ...style }}
      drag="x"
      dragConstraints={trackRef}
      dragElastic={0}
      dragMomentum={false}
      onDragStart={onDragStart}
      onDragEnd={onDragEnd}
      {...props}
    >
      {children ?? (
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden>
          <path
            d="M24 12 12.75 3v4.696H0v8.608h12.75V21z"
            fill="currentColor"
          />
        </svg>
      )}
    </motion.div>
  )
}
registry/default/example/slide-subscribe-shimmer.tsx
"use client"

import { useReducedMotion } from "motion/react"
import * as React from "react"

import { cn } from "@/lib/utils"

export type ShimmeringTextProps = React.ComponentProps<"span"> & {
  text: string
  /** Seconds for one sweep. */
  duration?: number
  isStopped?: boolean
}

export function ShimmeringText({
  text,
  duration = 2,
  isStopped = false,
  className,
  style,
  ...props
}: ShimmeringTextProps) {
  const reduce = useReducedMotion() ?? false
  const paused = reduce || isStopped

  return (
    <span
      className={cn(
        "inline-block select-none",
        "[--color:var(--muted-foreground)] [--shimmering-color:var(--foreground)]",
        paused
          ? "text-[var(--color)]"
          : cn(
              "bg-clip-text text-transparent",
              "[background-size:200%_100%]",
              "[background-image:linear-gradient(90deg,var(--color)_40%,var(--shimmering-color)_50%,var(--color)_60%)]",
              "[animation:shiny-text_var(--shimmer-duration,2s)_linear_infinite]",
              "motion-reduce:animate-none motion-reduce:bg-none motion-reduce:text-[var(--color)]",
            ),
        className,
      )}
      style={{ "--shimmer-duration": `${duration}s`, ...style } as React.CSSProperties}
      {...props}
    >
      {text}
    </span>
  )
}

More from uselayouts

Browse all components in this library →