Merge remote-tracking branch 'upstream/dev' into fix/documents

This commit is contained in:
Anish Sarkar 2026-02-04 18:19:09 +05:30
commit bbb367cc53
12 changed files with 895 additions and 93 deletions

View file

@ -0,0 +1,33 @@
"""Add DISCORD_JOIN to incentive task type enum
Revision ID: 91
Revises: 90
Changes:
1. Add DISCORD_JOIN value to incentivetasktype enum
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "91"
down_revision: str | None = "90"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add DISCORD_JOIN to incentivetasktype enum."""
op.execute("ALTER TYPE incentivetasktype ADD VALUE IF NOT EXISTS 'DISCORD_JOIN'")
def downgrade() -> None:
"""Remove DISCORD_JOIN from incentivetasktype enum.
Note: PostgreSQL doesn't support removing values from enums directly.
This would require recreating the enum type, which is complex and risky.
For safety, we leave the enum value in place during downgrade.
"""
pass

View file

@ -164,6 +164,7 @@ class IncentiveTaskType(str, Enum):
GITHUB_STAR = "GITHUB_STAR"
REDDIT_FOLLOW = "REDDIT_FOLLOW"
DISCORD_JOIN = "DISCORD_JOIN"
# Future tasks can be added here:
# GITHUB_ISSUE = "GITHUB_ISSUE"
# SOCIAL_SHARE = "SOCIAL_SHARE"
@ -185,6 +186,12 @@ INCENTIVE_TASKS_CONFIG = {
"pages_reward": 100,
"action_url": "https://www.reddit.com/r/SurfSense/",
},
IncentiveTaskType.DISCORD_JOIN: {
"title": "Join our Discord",
"description": "Join the SurfSense community on Discord",
"pages_reward": 100,
"action_url": "https://discord.gg/ejRNvftDp9",
},
# Future tasks can be configured here:
# IncentiveTaskType.GITHUB_ISSUE: {
# "title": "Create an issue",

View file

@ -41,6 +41,7 @@ import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-
import { Skeleton } from "@/components/ui/skeleton";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesElectric } from "@/hooks/use-messages-electric";
import { documentsApiService } from "@/lib/apis/documents-api.service";
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
import { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
@ -64,7 +65,6 @@ import {
trackChatMessageSent,
trackChatResponseReceived,
} from "@/lib/posthog/events";
import { documentsApiService } from "@/lib/apis/documents-api.service";
/**
* Extract thinking steps from message content

View file

@ -101,7 +101,7 @@ export default function RootLayout({
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme="light"
defaultTheme="system"
>
<RootProvider>
<ReactQueryClientProvider>

View file

@ -20,7 +20,10 @@ export function ContactFormGridWithDetails() {
Contact
</h2>
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 dark:text-neutral-400">
We'd love to hear from you. Schedule a meeting or send us an email.
We'd love to hear from you!
</p>
<p className="mt-4 max-w-lg text-center text-base text-neutral-600 dark:text-neutral-400">
Schedule a meeting with our Head of Product, Eric Lammertsma, or send us an email.
</p>
<div className="mt-10 flex flex-col items-center gap-6">

View file

@ -61,7 +61,8 @@ export function FeaturesCards() {
<CardContent>
<p className="text-sm">
Choose from 100+ leading LLMs and seamlessly call any model on demand.
Choose from 100+ leading LLMs, seamlessly calling any model on demand. Even run
on-prem local LLM inference via vLLM, Ollama, llama.cpp, LM Studio, and more.
</p>
</CardContent>
</Card>
@ -74,9 +75,9 @@ export function FeaturesCards() {
const CardDecorator = ({ children }: { children: ReactNode }) => (
<div
aria-hidden
className="relative mx-auto size-36 [mask-image:radial-gradient(ellipse_50%_50%_at_50%_50%,#000_70%,transparent_100%)]"
className="relative mx-auto size-36 mask-[radial-gradient(ellipse_50%_50%_at_50%_50%,#000_70%,transparent_100%)]"
>
<div className="absolute inset-0 [--border:black] dark:[--border:white] bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] bg-[size:24px_24px] opacity-10" />
<div className="absolute inset-0 [--border:black] dark:[--border:white] bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] bg-size-[24px_24px] opacity-10" />
<div className="bg-background absolute inset-0 m-auto flex size-12 items-center justify-center border-t border-l">
{children}
</div>

View file

@ -205,8 +205,8 @@ function ContactSalesButton() {
return (
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
<Link
href="https://calendly.com/eric-surfsense/surfsense-meeting"
target="_blank"
href="/contact"
//target="_blank"
rel="noopener noreferrer"
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-white px-6 py-2.5 text-sm font-semibold text-neutral-700 shadow-lg ring-1 ring-neutral-200/50 transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
>
@ -288,7 +288,7 @@ const CollisionMechanism = React.forwardRef<
}
};
const animationInterval = setInterval(checkCollision, 50);
const animationInterval = setInterval(checkCollision, 100);
return () => clearInterval(animationInterval);
}, [cycleCollisionDetected, containerRef]);
@ -338,7 +338,7 @@ const CollisionMechanism = React.forwardRef<
repeatDelay: beamOptions.repeatDelay || 0,
}}
className={cn(
"absolute left-96 top-20 m-auto h-14 w-px rounded-full bg-linear-to-t from-orange-500 via-yellow-500 to-transparent",
"absolute left-96 top-20 m-auto h-14 w-px rounded-full bg-linear-to-t from-orange-500 via-yellow-500 to-transparent will-change-transform",
beamOptions.className
)}
/>

View file

@ -19,10 +19,9 @@ export const Navbar = () => {
const [isScrolled, setIsScrolled] = useState(false);
const navItems = [
{ name: "Contact Us", link: "/contact" },
{ name: "Pricing", link: "/pricing" },
{ name: "Contact\u00A0Us", link: "/contact" },
{ name: "Changelog", link: "/changelog" },
// { name: "Sign In", link: "/login" },
{ name: "Docs", link: "/docs" },
];
@ -61,10 +60,13 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
: "bg-transparent border border-transparent"
)}
>
<div className="flex flex-1 flex-row items-center gap-0.5">
<Link
href="/"
className="flex flex-1 flex-row items-center gap-0.5 hover:opacity-80 transition-opacity"
>
<Logo className="h-8 w-8 rounded-md" />
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
</div>
</Link>
<div className="hidden flex-1 flex-row items-center justify-center space-x-2 text-sm font-medium text-zinc-600 transition duration-200 hover:text-zinc-800 lg:flex lg:space-x-2">
{navItems.map((navItem: any, idx: number) => (
<Link
@ -139,10 +141,13 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
)}
>
<div className="flex w-full flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<Link
href="/"
className="flex flex-row items-center gap-2 hover:opacity-80 transition-opacity"
>
<Logo className="h-8 w-8 rounded-md" />
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
</div>
</Link>
<button
type="button"
onClick={() => setOpen(!open)}

View file

@ -10,11 +10,11 @@ import {
useRef,
useState,
} from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
export interface DocumentMentionPickerRef {
selectHighlighted: () => void;

View file

@ -33,7 +33,7 @@ interface PricingProps {
export function Pricing({
plans,
title = "Simple, Transparent Pricing",
description = "Choose the plan that works for you\nAll plans include access to our platform, lead generation tools, and dedicated support.",
description = "Choose the plan that works for you\nAll plans include access to our SurfSense AI workspace and community support.",
}: PricingProps) {
const [isMonthly, setIsMonthly] = useState(true);
const isDesktop = useMediaQuery("(min-width: 768px)");
@ -69,7 +69,7 @@ export function Pricing({
};
return (
<div className="container py-20">
<div className="container mx-auto py-20">
<div className="text-center space-y-4 mb-12">
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl">{title}</h2>
<p className="text-muted-foreground text-lg whitespace-pre-line">{description}</p>

View file

@ -9,20 +9,39 @@ const demoPlans = [
yearlyPrice: "0",
period: "forever",
features: [
"Community support",
"Supports 100+ LLMs",
"Supports local Ollama or vLLM setups",
"6000+ Embedding Models",
"Supports OpenAI spec and LiteLLM",
"Supports local vLLM or Ollama setups",
"6000+ embedding models",
"50+ File extensions supported.",
"Podcasts support with local TTS providers.",
"Connects with 15+ external sources.",
"Connects with 15+ external sources, like Drive and Notion.",
"Cross-Browser Extension for dynamic webpages including authenticated content",
"Role-based access permissions",
"Collaboration and multiplayer features",
"Upcoming: Note Management",
"Role-based access control (RBAC)",
"Collaboration and team features",
],
description: "Open source version with powerful features",
buttonText: "Get Started",
buttonText: "Dive In",
href: "/docs",
isPopular: false,
},
{
name: "CLOUD",
price: "0",
yearlyPrice: "0",
period: "in beta",
features: [
"Everything in Community",
"Email support",
"Get started in seconds",
"Instant access to new features",
"Easy access from anywhere",
"Remote team management and collaboration",
],
description: "Instant access for individuals and teams",
buttonText: "Get Started",
href: "/",
isPopular: true,
},
{
@ -32,13 +51,16 @@ const demoPlans = [
period: "",
features: [
"Everything in Community",
"Priority Support",
"Advanced security features",
"Priority support",
"White-glove setup and deployment",
"Monthly managed updates and maintenance",
"On-prem or VPC deployment",
"Audit logs and compliance",
"SSO, OIDC & SAML",
"SLA guarantee",
"Uptime guarantee on VPC",
],
description: "For large organizations with specific needs",
description: "Professional, customized setup for large organizations",
buttonText: "Contact Sales",
href: "/contact",
isPopular: false,
@ -47,7 +69,7 @@ const demoPlans = [
function PricingBasic() {
return (
<Pricing plans={demoPlans} title="SurfSense Pricing" description="Choose that works for you" />
<Pricing plans={demoPlans} title="SurfSense Pricing" description="Choose what works for you" />
);
}

View file

@ -1,71 +1,802 @@
"use client";
import { MoonIcon, SunIcon } from "lucide-react";
import { motion } from "motion/react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useId, useState } from "react";
export function ThemeTogglerComponent() {
const { theme, setTheme } = useTheme();
import { cn } from "@/lib/utils";
const [isClient, setIsClient] = useState(false);
// ///////////////////////////////////////////////////////////////////////////
// Types
export type AnimationVariant =
| "circle"
| "rectangle"
| "gif"
| "polygon"
| "circle-blur";
export type AnimationStart =
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right"
| "center"
| "top-center"
| "bottom-center"
| "bottom-up"
| "top-down"
| "left-right"
| "right-left";
useEffect(() => {
setIsClient(true);
}, []);
return (
isClient && (
<Button
variant="ghost"
onClick={() => {
theme === "dark" ? setTheme("light") : setTheme("dark");
}}
className="w-8 h-8 flex hover:bg-gray-50 dark:hover:bg-white/[0.1] rounded-lg items-center cursor-pointer justify-center outline-none focus:ring-0 focus:outline-none active:ring-0 active:outline-none overflow-hidden"
>
{theme === "light" && (
<motion.div
key={theme}
initial={{
x: 40,
opacity: 0,
}}
animate={{
x: 0,
opacity: 1,
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
>
<SunIcon className="h-4 w-4 flex-shrink-0 dark:text-neutral-500 text-neutral-700" />
</motion.div>
)}
{theme === "dark" && (
<motion.div
key={theme}
initial={{
x: 40,
opacity: 0,
}}
animate={{
x: 0,
opacity: 1,
}}
transition={{
ease: "easeOut",
duration: 0.3,
}}
>
<MoonIcon className="h-4 w-4 flex-shrink-0 " />
</motion.div>
)}
<span className="sr-only">Toggle theme</span>
</Button>
)
);
interface Animation {
name: string;
css: string;
}
// ///////////////////////////////////////////////////////////////////////////
// Helper functions
const getPositionCoords = (position: AnimationStart) => {
switch (position) {
case "top-left":
return { cx: "0", cy: "0" };
case "top-right":
return { cx: "40", cy: "0" };
case "bottom-left":
return { cx: "0", cy: "40" };
case "bottom-right":
return { cx: "40", cy: "40" };
case "top-center":
return { cx: "20", cy: "0" };
case "bottom-center":
return { cx: "20", cy: "40" };
case "bottom-up":
case "top-down":
case "left-right":
case "right-left":
return { cx: "20", cy: "20" };
}
};
const generateSVG = (variant: AnimationVariant, start: AnimationStart) => {
if (variant === "circle-blur") {
if (start === "center") {
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="20" cy="20" r="18" fill="white" filter="url(%23blur)"/></svg>`;
}
const positionCoords = getPositionCoords(start);
if (!positionCoords) {
throw new Error(`Invalid start position: ${start}`);
}
const { cx, cy } = positionCoords;
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="${cx}" cy="${cy}" r="18" fill="white" filter="url(%23blur)"/></svg>`;
}
if (start === "center") return;
if (variant === "rectangle") return "";
const positionCoords = getPositionCoords(start);
if (!positionCoords) {
throw new Error(`Invalid start position: ${start}`);
}
const { cx, cy } = positionCoords;
if (variant === "circle") {
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><circle cx="${cx}" cy="${cy}" r="20" fill="white"/></svg>`;
}
return "";
};
const getTransformOrigin = (start: AnimationStart) => {
switch (start) {
case "top-left":
return "top left";
case "top-right":
return "top right";
case "bottom-left":
return "bottom left";
case "bottom-right":
return "bottom right";
case "top-center":
return "top center";
case "bottom-center":
return "bottom center";
case "bottom-up":
case "top-down":
case "left-right":
case "right-left":
return "center";
}
};
export const createAnimation = (
variant: AnimationVariant,
start: AnimationStart = "center",
blur = false,
url?: string,
): Animation => {
const svg = generateSVG(variant, start);
const transformOrigin = getTransformOrigin(start);
if (variant === "rectangle") {
const getClipPath = (direction: AnimationStart) => {
switch (direction) {
case "bottom-up":
return {
from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "top-down":
return {
from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "left-right":
return {
from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "right-left":
return {
from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "top-left":
return {
from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "top-right":
return {
from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "bottom-left":
return {
from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "bottom-right":
return {
from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
default:
return {
from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
}
};
const clipPath = getClipPath(start);
return {
name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: `
::view-transition-group(root) {
animation-duration: 0.7s;
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
animation-name: reveal-light-${start}${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: none;
z-index: -1;
}
.dark::view-transition-new(root) {
animation-name: reveal-dark-${start}${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
@keyframes reveal-dark-${start}${blur ? "-blur" : ""} {
from {
clip-path: ${clipPath.from};
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: ${clipPath.to};
${blur ? "filter: blur(0px);" : ""}
}
}
@keyframes reveal-light-${start}${blur ? "-blur" : ""} {
from {
clip-path: ${clipPath.from};
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: ${clipPath.to};
${blur ? "filter: blur(0px);" : ""}
}
}
`,
};
}
if (variant === "circle" && start == "center") {
return {
name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: `
::view-transition-group(root) {
animation-duration: 0.7s;
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
animation-name: reveal-light${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: none;
z-index: -1;
}
.dark::view-transition-new(root) {
animation-name: reveal-dark${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
@keyframes reveal-dark${blur ? "-blur" : ""} {
from {
clip-path: circle(0% at 50% 50%);
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: circle(100.0% at 50% 50%);
${blur ? "filter: blur(0px);" : ""}
}
}
@keyframes reveal-light${blur ? "-blur" : ""} {
from {
clip-path: circle(0% at 50% 50%);
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: circle(100.0% at 50% 50%);
${blur ? "filter: blur(0px);" : ""}
}
}
`,
};
}
if (variant === "gif") {
return {
name: `${variant}-${start}`,
css: `
::view-transition-group(root) {
animation-timing-function: var(--expo-in);
}
::view-transition-new(root) {
mask: url('${url}') center / 0 no-repeat;
animation: scale 3s;
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: scale 3s;
}
@keyframes scale {
0% {
mask-size: 0;
}
10% {
mask-size: 50vmax;
}
90% {
mask-size: 50vmax;
}
100% {
mask-size: 2000vmax;
}
}`,
};
}
if (variant === "circle-blur") {
if (start === "center") {
return {
name: `${variant}-${start}`,
css: `
::view-transition-group(root) {
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
mask: url('${svg}') center / 0 no-repeat;
mask-origin: content-box;
animation: scale 1s;
transform-origin: center;
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: scale 1s;
transform-origin: center;
z-index: -1;
}
@keyframes scale {
to {
mask-size: 350vmax;
}
}
`,
};
}
return {
name: `${variant}-${start}`,
css: `
::view-transition-group(root) {
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
mask: url('${svg}') ${start.replace("-", " ")} / 0 no-repeat;
mask-origin: content-box;
animation: scale 1s;
transform-origin: ${transformOrigin};
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: scale 1s;
transform-origin: ${transformOrigin};
z-index: -1;
}
@keyframes scale {
to {
mask-size: 350vmax;
}
}
`,
};
}
if (variant === "polygon") {
const getPolygonClipPaths = (position: AnimationStart) => {
switch (position) {
case "top-left":
return {
darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
};
case "top-right":
return {
darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)",
darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)",
lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)",
lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)",
};
default:
return {
darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
};
}
};
const clipPaths = getPolygonClipPaths(start);
return {
name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: `
::view-transition-group(root) {
animation-duration: 0.7s;
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
animation-name: reveal-light-${start}${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: none;
z-index: -1;
}
.dark::view-transition-new(root) {
animation-name: reveal-dark-${start}${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
@keyframes reveal-dark-${start}${blur ? "-blur" : ""} {
from {
clip-path: ${clipPaths.darkFrom};
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: ${clipPaths.darkTo};
${blur ? "filter: blur(0px);" : ""}
}
}
@keyframes reveal-light-${start}${blur ? "-blur" : ""} {
from {
clip-path: ${clipPaths.lightFrom};
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: ${clipPaths.lightTo};
${blur ? "filter: blur(0px);" : ""}
}
}
`,
};
}
// Handle circle variants with start positions using clip-path
if (variant === "circle" && start !== "center") {
const getClipPathPosition = (position: AnimationStart) => {
switch (position) {
case "top-left":
return "0% 0%";
case "top-right":
return "100% 0%";
case "bottom-left":
return "0% 100%";
case "bottom-right":
return "100% 100%";
case "top-center":
return "50% 0%";
case "bottom-center":
return "50% 100%";
default:
return "50% 50%";
}
};
const clipPosition = getClipPathPosition(start);
return {
name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: `
::view-transition-group(root) {
animation-duration: 1s;
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
animation-name: reveal-light-${start}${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: none;
z-index: -1;
}
.dark::view-transition-new(root) {
animation-name: reveal-dark-${start}${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
@keyframes reveal-dark-${start}${blur ? "-blur" : ""} {
from {
clip-path: circle(0% at ${clipPosition});
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: circle(150.0% at ${clipPosition});
${blur ? "filter: blur(0px);" : ""}
}
}
@keyframes reveal-light-${start}${blur ? "-blur" : ""} {
from {
clip-path: circle(0% at ${clipPosition});
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: circle(150.0% at ${clipPosition});
${blur ? "filter: blur(0px);" : ""}
}
}
`,
};
}
return {
name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: `
::view-transition-group(root) {
animation-timing-function: var(--expo-in);
}
::view-transition-new(root) {
mask: url('${svg}') ${start.replace("-", " ")} / 0 no-repeat;
mask-origin: content-box;
animation: scale-${start}${blur ? "-blur" : ""} 1s;
transform-origin: ${transformOrigin};
${blur ? "filter: blur(2px);" : ""}
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: scale-${start}${blur ? "-blur" : ""} 1s;
transform-origin: ${transformOrigin};
z-index: -1;
}
@keyframes scale-${start}${blur ? "-blur" : ""} {
from {
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
mask-size: 2000vmax;
${blur ? "filter: blur(0px);" : ""}
}
}
`,
};
};
// ///////////////////////////////////////////////////////////////////////////
// Custom hook for theme toggle functionality
export const useThemeToggle = ({
variant = "circle",
start = "center",
blur = false,
gifUrl = "",
}: {
variant?: AnimationVariant;
start?: AnimationStart;
blur?: boolean;
gifUrl?: string;
} = {}) => {
const { theme, setTheme, resolvedTheme } = useTheme();
const [isDark, setIsDark] = useState(false);
// Sync isDark state with resolved theme after hydration
useEffect(() => {
setIsDark(resolvedTheme === "dark");
}, [resolvedTheme]);
const styleId = "theme-transition-styles";
const updateStyles = useCallback((css: string) => {
if (typeof window === "undefined") return;
let styleElement = document.getElementById(styleId) as HTMLStyleElement;
if (!styleElement) {
styleElement = document.createElement("style");
styleElement.id = styleId;
document.head.appendChild(styleElement);
}
styleElement.textContent = css;
}, []);
const toggleTheme = useCallback(() => {
setIsDark(!isDark);
const animation = createAnimation(variant, start, blur, gifUrl);
updateStyles(animation.css);
if (typeof window === "undefined") return;
const switchTheme = () => {
setTheme(theme === "light" ? "dark" : "light");
};
if (!document.startViewTransition) {
switchTheme();
return;
}
document.startViewTransition(switchTheme);
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]);
const setCrazyLightTheme = useCallback(() => {
setIsDark(false);
const animation = createAnimation(variant, start, blur, gifUrl);
updateStyles(animation.css);
if (typeof window === "undefined") return;
const switchTheme = () => {
setTheme("light");
};
if (!document.startViewTransition) {
switchTheme();
return;
}
document.startViewTransition(switchTheme);
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
const setCrazyDarkTheme = useCallback(() => {
setIsDark(true);
const animation = createAnimation(variant, start, blur, gifUrl);
updateStyles(animation.css);
if (typeof window === "undefined") return;
const switchTheme = () => {
setTheme("dark");
};
if (!document.startViewTransition) {
switchTheme();
return;
}
document.startViewTransition(switchTheme);
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
const setCrazySystemTheme = useCallback(() => {
if (typeof window === "undefined") return;
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
setIsDark(prefersDark);
const animation = createAnimation(variant, start, blur, gifUrl);
updateStyles(animation.css);
const switchTheme = () => {
setTheme("system");
};
if (!document.startViewTransition) {
switchTheme();
return;
}
document.startViewTransition(switchTheme);
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
return {
isDark,
setIsDark,
toggleTheme,
setCrazyLightTheme,
setCrazyDarkTheme,
setCrazySystemTheme,
};
};
// ///////////////////////////////////////////////////////////////////////////
// Theme Toggle Button Component (Sun/Moon Style)
export const ThemeToggleButton = ({
className = "",
variant = "circle",
start = "center",
blur = false,
gifUrl = "",
}: {
className?: string;
variant?: AnimationVariant;
start?: AnimationStart;
blur?: boolean;
gifUrl?: string;
}) => {
const { isDark, toggleTheme } = useThemeToggle({
variant,
start,
blur,
gifUrl,
});
const clipId = useId();
const clipPathId = `theme-toggle-clip-${clipId}`;
return (
<button
type="button"
className={cn(
"size-10 cursor-pointer rounded-full p-2 transition-all duration-300 active:scale-95 bg-transparent",
isDark ? "text-white" : "text-black",
className,
)}
onClick={toggleTheme}
aria-label="Toggle theme"
>
<span className="sr-only">Toggle theme</span>
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
fill="currentColor"
strokeLinecap="round"
viewBox="0 0 32 32"
>
<clipPath id={clipPathId}>
<motion.path
animate={{ y: isDark ? 10 : 0, x: isDark ? -12 : 0 }}
transition={{ ease: "easeInOut", duration: 0.35 }}
d="M0-5h30a1 1 0 0 0 9 13v24H0Z"
/>
</clipPath>
<g clipPath={`url(#${clipPathId})`}>
<motion.circle
animate={{ r: isDark ? 10 : 8 }}
transition={{ ease: "easeInOut", duration: 0.35 }}
cx="16"
cy="16"
/>
<motion.g
animate={{
rotate: isDark ? -100 : 0,
scale: isDark ? 0.5 : 1,
opacity: isDark ? 0 : 1,
}}
transition={{ ease: "easeInOut", duration: 0.35 }}
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M16 5.5v-4" />
<path d="M16 30.5v-4" />
<path d="M1.5 16h4" />
<path d="M26.5 16h4" />
<path d="m23.4 8.6 2.8-2.8" />
<path d="m5.7 26.3 2.9-2.9" />
<path d="m5.8 5.8 2.8 2.8" />
<path d="m23.4 23.4 2.9 2.9" />
</motion.g>
</g>
</svg>
</button>
);
};
// ///////////////////////////////////////////////////////////////////////////
// Backwards compatible export (alias for ThemeToggleButton with default settings)
export function ThemeTogglerComponent() {
return (
<ThemeToggleButton
variant="circle"
start="top-right"
className="size-8"
/>
);
}
/**
* Skiper 26 Theme_buttons_002 React + CSS + transition view api https://developer.chrome.com/docs/web-platform/view-transitions/
* Original concept from rudrodip
* Inspired by from https://github.com/rudrodip/theme-toggle-effect
* We respect the original creators. This is an inspired rebuild with our own taste and does not claim any ownership.
* These animations aren't associated with the rudrodip . They're independent recreations meant to study interaction design
*
* License & Usage:
* - Free to use and modify in both personal and commercial projects.
* - Attribution to Skiper UI is required when using the free version.
* - No attribution required with Skiper UI Pro.
*
* Feedback and contributions are welcome.
*
* Author: @gurvinder-singh02
* Website: https://gxuri.in
* Twitter: https://x.com/Gur__vi
*/