mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Landing page agent focus (#47989)
This commit is contained in:
1 parent
e3d7267845
commit
2d745edfb4
13 files changed
+785
-194
No files matched your search
+68
-37
@@ -5,6 +5,7 @@ import { cn } from 'ui'
|
||||
import { IconPanel } from 'ui-patterns/IconPanel'
|
||||
import { TextLink } from 'ui-patterns/TextLink'
|
||||
|
||||
import { FrameworkQuickstarts } from '@/components/HomePageCover'
|
||||
import { MIGRATION_PAGES } from '@/components/Navigation/NavigationMenu/NavigationMenu.constants'
|
||||
import { GlassPanelWithIconPicker } from '@/features/ui/GlassPanelWithIconPicker'
|
||||
import { IconPanelWithIconPicker } from '@/features/ui/IconPanelWithIconPicker'
|
||||
@@ -45,7 +46,7 @@ const products = [
|
||||
href: '/guides/database/overview',
|
||||
description:
|
||||
'Supabase provides a full Postgres database for every project with Realtime functionality, database backups, extensions, and more.',
|
||||
span: 'col-span-12 md:col-span-6',
|
||||
span: 'col-span-12',
|
||||
},
|
||||
{
|
||||
title: 'Auth',
|
||||
@@ -226,26 +227,55 @@ const additionalResources = [
|
||||
const HomePage = () => (
|
||||
<HomeLayout>
|
||||
<div className="flex flex-col">
|
||||
<h2 id="products">Products</h2>
|
||||
<ul className="grid grid-cols-12 gap-6 not-prose [&_svg]:text-brand-600">
|
||||
{products.map((product) => {
|
||||
return (
|
||||
<li key={product.title} className={cn(product.span ?? 'col-span-12 md:col-span-4')}>
|
||||
<Link href={product.href} passHref>
|
||||
<GlassPanelWithIconPicker {...product}>
|
||||
{product.description}
|
||||
</GlassPanelWithIconPicker>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{isFeatureEnabled('docs:full_getting_started') && (
|
||||
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
|
||||
<div className="col-span-4 flex flex-col gap-1 [&_h2]:m-0">
|
||||
<h2 id="connect-a-framework" className="group scroll-mt-24">
|
||||
Connect a framework
|
||||
</h2>
|
||||
<p className="m-0 p-0 text-sm text-foreground-light">
|
||||
Start with a framework quickstart and connect your project in minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:grid grid-cols-12 gap-6 py-12 border-b">
|
||||
<div className="col-span-4">
|
||||
<h2 id="postgres-integrations" className="scroll-mt-24 m-0">
|
||||
Modules
|
||||
<div className="col-span-8 not-prose">
|
||||
<FrameworkQuickstarts />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
|
||||
<div className="col-span-4 flex flex-col gap-1 [&_h2]:m-0">
|
||||
<h2 id="products" className="group scroll-mt-24">
|
||||
Build your backend
|
||||
</h2>
|
||||
<p className="m-0 p-0 text-sm text-foreground-light">
|
||||
Build with a complete backend platform, from your database to your application logic.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="col-span-8 grid grid-cols-12 gap-6 not-prose [&_svg]:text-brand-600">
|
||||
{products.map((product) => {
|
||||
return (
|
||||
<li key={product.title} className={cn(product.span ?? 'col-span-12 md:col-span-6')}>
|
||||
<Link href={product.href} passHref>
|
||||
<GlassPanelWithIconPicker {...product}>
|
||||
{product.description}
|
||||
</GlassPanelWithIconPicker>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
|
||||
<div className="col-span-4 flex flex-col gap-1 [&_h2]:m-0">
|
||||
<h2 id="postgres-integrations" className="scroll-mt-24">
|
||||
Extend your database
|
||||
</h2>
|
||||
<p className="m-0 p-0 text-sm text-foreground-light">
|
||||
Extend your database with built-in tools for AI, APIs, scheduled jobs, and queues.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
|
||||
{postgresIntegrations.map((integration) => (
|
||||
@@ -261,15 +291,14 @@ const HomePage = () => (
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:grid grid-cols-12 gap-6 py-12 border-b">
|
||||
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
|
||||
<div className="col-span-4 flex flex-col gap-1 [&_h2]:m-0 [&_h3]:m-0">
|
||||
<div className="md:max-w-xs 2xl:max-w-none">
|
||||
<div className="flex items-center gap-3 mb-3 text-brand-600">
|
||||
<h2 id="client-libraries" className="group scroll-mt-24">
|
||||
Client Libraries
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<h2 id="client-libraries" className="group scroll-mt-24">
|
||||
Use a client library
|
||||
</h2>
|
||||
<p className="m-0 p-0 text-sm text-foreground-light">
|
||||
Use Supabase from the language and framework your application is built with.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
|
||||
@@ -291,7 +320,7 @@ const HomePage = () => (
|
||||
</div>
|
||||
</div>
|
||||
{isFeatureEnabled('docs:full_getting_started') && (
|
||||
<div className="flex flex-col lg:grid grid-cols-12 gap-6 py-12 border-b">
|
||||
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
|
||||
<div className="col-span-4 flex flex-col gap-1 [&_h2]:m-0">
|
||||
<h2 id="migrate-to-supabase" className="group scroll-mt-24">
|
||||
Migrate to Supabase
|
||||
@@ -322,20 +351,22 @@ const HomePage = () => (
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-6 py-12 border-b">
|
||||
<div className="col-span-4 flex flex-col gap-1">
|
||||
<h2 id="additional-resources" className="group scroll-mt-24 m-0">
|
||||
Additional resources
|
||||
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
|
||||
<div className="col-span-4 flex flex-col gap-1 [&_h2]:m-0">
|
||||
<h2 id="additional-resources" className="group scroll-mt-24">
|
||||
Explore more
|
||||
</h2>
|
||||
<p className="m-0 p-0 text-sm text-foreground-light">
|
||||
Explore the tools, integrations, and guides that help you get more from Supabase.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="grid grid-cols-12 gap-6 not-prose">
|
||||
<ul className="col-span-8 grid grid-cols-12 gap-6 not-prose">
|
||||
{additionalResources.map((resource) => {
|
||||
return (
|
||||
<li key={resource.title} className="col-span-12 md:col-span-6 lg:col-span-3">
|
||||
<li key={resource.title} className="col-span-12 md:col-span-6">
|
||||
<Link
|
||||
href={resource.href}
|
||||
className="col-span-12 md:col-span-6 lg:col-span-3"
|
||||
passHref
|
||||
target={resource.external ? '_blank' : undefined}
|
||||
>
|
||||
@@ -349,12 +380,12 @@ const HomePage = () => (
|
||||
</ul>
|
||||
</div>
|
||||
{isFeatureEnabled('docs:full_getting_started') && (
|
||||
<div className="flex flex-col lg:grid grid-cols-12 gap-6 py-12">
|
||||
<div className="flex flex-col gap-6 py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
|
||||
<div className="col-span-4 flex flex-col gap-1">
|
||||
<div className="md:max-w-xs 2xl:max-w-none">
|
||||
<div className="flex items-center gap-3 mb-3 text-brand-600">
|
||||
<h2 id="self-hosting" className="group scroll-mt-24 m-0">
|
||||
Self-Hosting
|
||||
Self-host Supabase
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-foreground-light text-sm m-0 p-0">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import React, { type PropsWithChildren } from 'react'
|
||||
import React, { type ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
const DocsCoverLogo = (props: PropsWithChildren) => {
|
||||
const DocsCoverLogo = ({ className, ...props }: ComponentPropsWithoutRef<'div'>) => {
|
||||
const pathMotionConfig = {
|
||||
initial: { pathLength: 0 },
|
||||
animate: { pathLength: 1 },
|
||||
@@ -24,7 +24,7 @@ const DocsCoverLogo = (props: PropsWithChildren) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[60px] md:w-[150px] [&_svg]" {...props}>
|
||||
<div className={className} {...props}>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.svg
|
||||
width="100%"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const setupCommand = {
|
||||
installCli: 'npm install -g supabase',
|
||||
installPlugin: 'npx plugins add supabase-community/supabase-plugin',
|
||||
initialize: 'supabase init',
|
||||
} as const
|
||||
|
||||
export const setupCommands = [setupCommand.installCli, setupCommand.installPlugin].join('\n')
|
||||
|
||||
export const setupPrompt = `Help me get set up with Supabase. Do the following: 1. Install the Supabase CLI globally with \`${setupCommand.installCli}\`. 2. Install the Supabase Plugin with \`${setupCommand.installPlugin}\`. 3. Review my project and determine whether Supabase is already initialized. If it is not initialized, run \`${setupCommand.initialize}\`. 4. Suggest the most relevant next steps.`
|
||||
@@ -1,180 +1,195 @@
|
||||
'use client'
|
||||
|
||||
// End of third-party imports
|
||||
import {
|
||||
Prompt,
|
||||
PromptContent,
|
||||
PromptCopy,
|
||||
PromptPanel,
|
||||
PromptTitle,
|
||||
} from '~/features/ui/PromptPanel'
|
||||
import { isFeatureEnabled, useBreakpoint } from 'common'
|
||||
import { ChevronRight, Sparkles } from 'lucide-react'
|
||||
import { Sparkles, Terminal } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import { cn } from 'ui'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { IconPanel } from 'ui-patterns/IconPanel'
|
||||
|
||||
import { getCustomContent } from '../lib/custom-content/getCustomContent'
|
||||
import DocsCoverLogo from './DocsCoverLogo'
|
||||
import { setupCommand, setupCommands, setupPrompt } from './HomePageCover.constants'
|
||||
|
||||
const {
|
||||
sdkDart: sdkDartEnabled,
|
||||
sdkKotlin: sdkKotlinEnabled,
|
||||
sdkSwift: sdkSwiftEnabled,
|
||||
} = isFeatureEnabled(['sdk:dart', 'sdk:kotlin', 'sdk:swift'])
|
||||
const fullGettingStartedEnabled = isFeatureEnabled('docs:full_getting_started')
|
||||
|
||||
function AiPrompt({ className }: { className?: string }) {
|
||||
function SetupPrompt({ cliCode }: { cliCode: ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
'group',
|
||||
'w-fit rounded-full border px-3 py-1 flex gap-2 items-center text-foreground-light text-sm',
|
||||
'hover:border-brand hover:text-brand-link focus-visible:text-brand-link',
|
||||
'transition-colors',
|
||||
className
|
||||
)}
|
||||
href="/guides/ai-tools/ai-prompts"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
Start with Supabase AI prompts
|
||||
<ChevronRight size={14} className="group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
<PromptPanel>
|
||||
<Prompt value="prompt" expandable>
|
||||
<PromptTitle icon={<Sparkles />}>AI Prompt</PromptTitle>
|
||||
<PromptCopy>{setupPrompt}</PromptCopy>
|
||||
<PromptContent>
|
||||
Help me get set up with Supabase. Do the following: 1. Install the Supabase CLI globally
|
||||
with{' '}
|
||||
<code className="shimmer-none rounded bg-surface-200 px-1 py-0.5 font-mono text-xs text-foreground">
|
||||
{setupCommand.installCli}
|
||||
</code>
|
||||
. 2. Install the Supabase Plugin with{' '}
|
||||
<code className="shimmer-none rounded bg-surface-200 px-1 py-0.5 font-mono text-xs text-foreground">
|
||||
{setupCommand.installPlugin}
|
||||
</code>
|
||||
. 3. Review my project and determine whether Supabase is already initialized. If it is not
|
||||
initialized, run{' '}
|
||||
<code className="shimmer-none rounded bg-surface-200 px-1 py-0.5 font-mono text-xs text-foreground">
|
||||
{setupCommand.initialize}
|
||||
</code>
|
||||
. 4. Suggest the most relevant next steps.
|
||||
</PromptContent>
|
||||
</Prompt>
|
||||
<Prompt value="cli">
|
||||
<PromptTitle icon={<Terminal />}>CLI</PromptTitle>
|
||||
<PromptCopy>{setupCommands}</PromptCopy>
|
||||
<PromptContent shimmer={false}>{cliCode}</PromptContent>
|
||||
</Prompt>
|
||||
</PromptPanel>
|
||||
)
|
||||
}
|
||||
|
||||
const HomePageCover = (props) => {
|
||||
const frameworks = [
|
||||
{
|
||||
tooltip: 'ReactJS',
|
||||
icon: '/docs/img/icons/react-icon',
|
||||
href: '/guides/getting-started/quickstarts/reactjs',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Next.js',
|
||||
icon: '/docs/img/icons/nextjs-icon',
|
||||
href: '/guides/getting-started/quickstarts/nextjs',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'TanStack Start',
|
||||
icon: '/docs/img/icons/tanstack-icon',
|
||||
href: '/guides/getting-started/quickstarts/tanstack',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
tooltip: 'Astro.js',
|
||||
icon: '/docs/img/icons/astro-icon',
|
||||
href: '/guides/getting-started/quickstarts/astrojs',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
tooltip: 'Vue',
|
||||
icon: '/docs/img/icons/vuejs-icon',
|
||||
href: '/guides/getting-started/quickstarts/vue',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Nuxt',
|
||||
icon: '/docs/img/icons/nuxt-icon',
|
||||
href: '/guides/getting-started/quickstarts/nuxtjs',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'iOS Swift',
|
||||
icon: '/docs/img/icons/swift-icon-orange',
|
||||
href: '/guides/getting-started/quickstarts/ios-swiftui',
|
||||
enabled: sdkSwiftEnabled,
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Android Kotlin',
|
||||
icon: '/docs/img/icons/kotlin-icon',
|
||||
href: '/guides/getting-started/quickstarts/kotlin',
|
||||
enabled: sdkKotlinEnabled,
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Expo React Native',
|
||||
icon: '/docs/img/icons/expo-icon',
|
||||
href: '/guides/getting-started/quickstarts/expo-react-native',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
tooltip: 'Flutter',
|
||||
icon: '/docs/img/icons/flutter-icon',
|
||||
href: '/guides/getting-started/quickstarts/flutter',
|
||||
enabled: sdkDartEnabled,
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Python',
|
||||
icon: '/docs/img/icons/python-icon',
|
||||
href: '/guides/getting-started/quickstarts/flask',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
]
|
||||
|
||||
export function FrameworkQuickstarts() {
|
||||
const isXs = useBreakpoint(639)
|
||||
const iconSize = isXs ? 'sm' : 'lg'
|
||||
const { homepageHeading } = getCustomContent(['homepage:heading'])
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isLightMode = !resolvedTheme?.includes('dark')
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
const isLightMode = isMounted && resolvedTheme === 'light'
|
||||
|
||||
const frameworks = [
|
||||
{
|
||||
tooltip: 'ReactJS',
|
||||
icon: '/docs/img/icons/react-icon',
|
||||
href: '/guides/getting-started/quickstarts/reactjs',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Next.js',
|
||||
icon: '/docs/img/icons/nextjs-icon',
|
||||
href: '/guides/getting-started/quickstarts/nextjs',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'TanStack Start',
|
||||
icon: '/docs/img/icons/tanstack-icon',
|
||||
href: '/guides/getting-started/quickstarts/tanstack',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
tooltip: 'Astro.js',
|
||||
icon: '/docs/img/icons/astro-icon',
|
||||
href: '/guides/getting-started/quickstarts/astrojs',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
tooltip: 'Vue',
|
||||
icon: '/docs/img/icons/vuejs-icon',
|
||||
href: '/guides/getting-started/quickstarts/vue',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Nuxt',
|
||||
icon: '/docs/img/icons/nuxt-icon',
|
||||
href: '/guides/getting-started/quickstarts/nuxtjs',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'iOS Swift',
|
||||
icon: '/docs/img/icons/swift-icon-orange',
|
||||
href: '/guides/getting-started/quickstarts/ios-swiftui',
|
||||
enabled: sdkSwiftEnabled,
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Android Kotlin',
|
||||
icon: '/docs/img/icons/kotlin-icon',
|
||||
href: '/guides/getting-started/quickstarts/kotlin',
|
||||
enabled: sdkKotlinEnabled,
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Expo React Native',
|
||||
icon: '/docs/img/icons/expo-icon',
|
||||
href: '/guides/getting-started/quickstarts/expo-react-native',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
tooltip: 'Flutter',
|
||||
icon: '/docs/img/icons/flutter-icon',
|
||||
href: '/guides/getting-started/quickstarts/flutter',
|
||||
enabled: sdkDartEnabled,
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Python',
|
||||
icon: '/docs/img/icons/python-icon',
|
||||
href: '/guides/getting-started/quickstarts/flask',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
]
|
||||
|
||||
const GettingStarted = () => (
|
||||
<div
|
||||
className="
|
||||
border bg-background
|
||||
relative overflow-hidden
|
||||
grid grid-cols-12
|
||||
rounded-lg
|
||||
p-5 md:p-8
|
||||
"
|
||||
>
|
||||
<div className="col-span-full flex flex-col md:flex-row xl:flex-col justify-between gap-3">
|
||||
<div className="md:max-w-xs shrink w-fit xl:max-w-none">
|
||||
<div className="flex flex-col gap-1 mb-3">
|
||||
<h2 className="text-2xl text-foreground">Getting Started</h2>
|
||||
<p className="text-foreground-light text-sm">
|
||||
Set up and connect a database in just a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<div className="flex flex-wrap md:grid md:grid-cols-5 gap-2 sm:gap-3">
|
||||
{frameworks
|
||||
.filter((framework) => framework.enabled !== false)
|
||||
.map((framework, i) => {
|
||||
const iconToUse =
|
||||
framework.hasLightIcon && isLightMode ? `${framework.icon}-light` : framework.icon
|
||||
|
||||
return (
|
||||
<Link key={i} href={framework.href} passHref className="no-underline">
|
||||
<IconPanel iconSize={iconSize} tooltip={framework.tooltip} icon={iconToUse} />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<AiPrompt className="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
useEffect(() => setIsMounted(true), [])
|
||||
|
||||
return (
|
||||
<div className="relative z-10 w-full bg-alternative border-b max-w-none mb-16 md:mb-12 xl:mb-0">
|
||||
<div className="max-w-7xl px-5 mx-auto py-8 sm:pb-16 sm:pt-12 xl:pt-16 flex flex-col xl:flex-row justify-between gap-12 xl:gap-12">
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-8 items-start sm:items-center w-full max-w-xl xl:max-w-132">
|
||||
<DocsCoverLogo aria-hidden="true" />
|
||||
<div className="flex flex-col">
|
||||
<h1 className="m-0 mb-3 text-2xl sm:text-3xl text-foreground">
|
||||
{homepageHeading || props.title}
|
||||
</h1>
|
||||
<p className="m-0 text-foreground-light">
|
||||
Learn how to get up and running with Supabase through tutorials, APIs and platform
|
||||
resources.
|
||||
</p>
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{frameworks
|
||||
.filter((framework) => framework.enabled !== false)
|
||||
.map((framework) => {
|
||||
const iconToUse =
|
||||
framework.hasLightIcon && isLightMode ? `${framework.icon}-light` : framework.icon
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={framework.tooltip}
|
||||
href={framework.href}
|
||||
passHref
|
||||
className="col-span-6 no-underline md:col-span-4"
|
||||
>
|
||||
<IconPanel
|
||||
iconSize={iconSize}
|
||||
tooltip={framework.tooltip}
|
||||
title={framework.tooltip}
|
||||
icon={iconToUse}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const HomePageCover = ({ title, cliCode }: { title: string; cliCode: ReactNode }) => {
|
||||
const { homepageHeading } = getCustomContent(['homepage:heading'])
|
||||
|
||||
return (
|
||||
<div className="w-full border-b bg-muted/10">
|
||||
<div className="mx-auto max-w-7xl px-6 py-16">
|
||||
<div className="flex flex-col items-start gap-10 xl:flex-row xl:items-center xl:gap-16">
|
||||
<div className="flex w-full flex-1 flex-col gap-4 sm:flex-row sm:items-center sm:gap-8">
|
||||
<DocsCoverLogo aria-hidden="true" className="w-[60px] shrink-0 md:w-[100px]" />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<h1 className="m-0 text-4xl text-foreground">{homepageHeading || title}</h1>
|
||||
<p className="m-0 mt-3 text-xl leading-7 text-foreground-light">
|
||||
Learn how to get up and running with Supabase through tutorials, APIs and platform
|
||||
resources.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{fullGettingStartedEnabled && (
|
||||
<div className="w-full max-w-xl xl:max-w-[478px] xl:shrink-0">
|
||||
<SetupPrompt cliCode={cliCode} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isFeatureEnabled('docs:full_getting_started') && (
|
||||
<div className="w-full xl:max-w-[440px] -mb-40">
|
||||
<GettingStarted />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ import AuthProviders from '~/components/AuthProviders'
|
||||
import { AuthSmsProviderConfig } from '~/components/AuthSmsProviderConfig'
|
||||
import ButtonCard from '~/components/ButtonCard'
|
||||
import { ComputeDiskLimitsTable } from '~/components/ComputeDiskLimitsTable'
|
||||
import { CustomContent } from '~/components/CustomContent'
|
||||
import { ContentListings } from '~/components/ContentListings'
|
||||
import { CustomContent } from '~/components/CustomContent'
|
||||
import { Extensions } from '~/components/Extensions'
|
||||
import Image, { type ImageProps } from '~/components/Image'
|
||||
import { McpCiConfigBlock } from '~/components/McpCiConfigBlock'
|
||||
@@ -40,6 +40,7 @@ import { Heading } from 'ui/src/components/CustomHTMLElements'
|
||||
import { AgentPluginsPanel } from '../ui/AgentPluginsPanel'
|
||||
import { ErrorCodes } from '../ui/ErrorCodes'
|
||||
import { McpConfigPanel } from '../ui/McpConfigPanel'
|
||||
import { Prompt, PromptContent, PromptCopy, PromptPanel, PromptTitle } from '../ui/PromptPanel'
|
||||
|
||||
// Wrap Admonition for Docs-specific styling (within MDX prose, requires a margin-bottom)
|
||||
const AdmonitionWithMargin = (props: AdmonitionProps) => {
|
||||
@@ -96,6 +97,11 @@ const components = {
|
||||
NamedCodeBlock,
|
||||
NavData,
|
||||
ProjectConfigVariables,
|
||||
Prompt,
|
||||
PromptContent,
|
||||
PromptCopy,
|
||||
PromptPanel,
|
||||
PromptTitle,
|
||||
RealtimeLimitsEstimator,
|
||||
RegionsList,
|
||||
SmartRegionsList,
|
||||
|
||||
@@ -26,12 +26,14 @@ export async function CodeBlock({
|
||||
contents,
|
||||
children,
|
||||
skipTypeGeneration,
|
||||
hideControls = false,
|
||||
}: PropsWithChildren<{
|
||||
className?: string
|
||||
lang?: string
|
||||
lineNumbers?: boolean
|
||||
contents?: string
|
||||
skipTypeGeneration?: boolean
|
||||
hideControls?: boolean
|
||||
}>) {
|
||||
let code = (contents || extractCode(children)).trim()
|
||||
const lang = tryToBundledLanguage(langSetting || '') || extractLang(children)
|
||||
@@ -73,7 +75,7 @@ export async function CodeBlock({
|
||||
role="group"
|
||||
aria-roledescription="code block"
|
||||
>
|
||||
<CodeBlockControls content={code.trim()} />
|
||||
{!hideControls && <CodeBlockControls content={code.trim()} />}
|
||||
<pre>
|
||||
<code className={lineNumbers ? 'grid grid-cols-[auto_1fr]' : ''}>
|
||||
{lineNumbers ? (
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useId,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
// shadcn tabs from packages/ui/src/components/shadcn/ui/tabs.tsx
|
||||
import { cn, copyToClipboard, Tabs, TabsContent, TabsList, TabsTrigger } from 'ui'
|
||||
|
||||
type PromptTitleProps = {
|
||||
children: ReactNode
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
type PromptCopyProps = {
|
||||
children: string
|
||||
}
|
||||
|
||||
type PromptContentProps = {
|
||||
children: ReactNode
|
||||
/** Draws attention with a text shimmer. Defaults to true. */
|
||||
shimmer?: boolean
|
||||
}
|
||||
|
||||
type PromptProps = {
|
||||
children: ReactNode
|
||||
/** Stable tab id. Auto-generated when omitted. */
|
||||
value?: string
|
||||
/** When true, content starts collapsed with a show more / show less control. */
|
||||
expandable?: boolean
|
||||
}
|
||||
|
||||
type PromptPanelProps = {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
type CollectedPrompt = {
|
||||
value: string
|
||||
title: ReactNode
|
||||
icon?: ReactNode
|
||||
copyValue: string
|
||||
content: ReactNode
|
||||
expandable: boolean
|
||||
shimmer: boolean
|
||||
}
|
||||
|
||||
function PromptTitle(_props: PromptTitleProps) {
|
||||
return null
|
||||
}
|
||||
PromptTitle.displayName = 'PromptTitle'
|
||||
|
||||
function PromptCopy(_props: PromptCopyProps) {
|
||||
return null
|
||||
}
|
||||
PromptCopy.displayName = 'PromptCopy'
|
||||
|
||||
function PromptContent(_props: PromptContentProps) {
|
||||
return null
|
||||
}
|
||||
PromptContent.displayName = 'PromptContent'
|
||||
|
||||
function Prompt(_props: PromptProps) {
|
||||
return null
|
||||
}
|
||||
Prompt.displayName = 'Prompt'
|
||||
|
||||
function isElementOfType(child: ReactNode, type: { displayName?: string }) {
|
||||
return (
|
||||
isValidElement(child) &&
|
||||
typeof child.type !== 'string' &&
|
||||
'displayName' in child.type &&
|
||||
child.type.displayName === type.displayName
|
||||
)
|
||||
}
|
||||
|
||||
function collectPrompt(prompt: ReactElement<PromptProps>, index: number): CollectedPrompt {
|
||||
let title: ReactNode = `Prompt ${index + 1}`
|
||||
let icon: ReactNode | undefined
|
||||
let copyValue = ''
|
||||
let content: ReactNode = null
|
||||
let shimmer = true
|
||||
|
||||
Children.forEach(prompt.props.children, (child) => {
|
||||
if (isElementOfType(child, PromptTitle)) {
|
||||
const props = (child as ReactElement<PromptTitleProps>).props
|
||||
title = props.children
|
||||
icon = props.icon
|
||||
} else if (isElementOfType(child, PromptCopy)) {
|
||||
copyValue = (child as ReactElement<PromptCopyProps>).props.children
|
||||
} else if (isElementOfType(child, PromptContent)) {
|
||||
const props = (child as ReactElement<PromptContentProps>).props
|
||||
content = props.children
|
||||
shimmer = props.shimmer !== false
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
value: prompt.props.value ?? `prompt-${index}`,
|
||||
title,
|
||||
icon,
|
||||
copyValue,
|
||||
content,
|
||||
expandable: Boolean(prompt.props.expandable),
|
||||
shimmer,
|
||||
}
|
||||
}
|
||||
|
||||
function collectPrompts(children: ReactNode): CollectedPrompt[] {
|
||||
return Children.toArray(children)
|
||||
.filter((child): child is ReactElement<PromptProps> => isElementOfType(child, Prompt))
|
||||
.map((prompt, index) => collectPrompt(prompt, index))
|
||||
}
|
||||
|
||||
function ExpandableContent({ children }: { children: ReactNode }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<div className={cn(!isExpanded && 'max-h-30 overflow-hidden')}>{children}</div>
|
||||
{!isExpanded && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-background to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((expanded) => !expanded)}
|
||||
className="mt-2 text-sm text-brand-link transition-colors hover:text-brand focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground-muted"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptBody({
|
||||
prompt,
|
||||
shimmerEnabled,
|
||||
}: {
|
||||
prompt: CollectedPrompt
|
||||
shimmerEnabled: boolean
|
||||
}) {
|
||||
const [shimmerMounted, setShimmerMounted] = useState(prompt.shimmer)
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={cn(shimmerMounted && 'shimmer')}
|
||||
data-shimmer-fading={shimmerMounted && !shimmerEnabled ? true : undefined}
|
||||
onTransitionEnd={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (shimmerMounted && !shimmerEnabled) {
|
||||
setShimmerMounted(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{prompt.content}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3.5 text-sm leading-6 text-foreground-light">
|
||||
{prompt.expandable ? <ExpandableContent>{content}</ExpandableContent> : content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyButton({ label, value }: { label: string; value: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
|
||||
const timeout = window.setTimeout(() => setCopied(false), 2000)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [copied])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
copyToClipboard(value, () => {
|
||||
setCopied(true)
|
||||
})
|
||||
}}
|
||||
className="rounded-sm p-1.5 text-foreground-muted transition-colors hover:bg-surface-200 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground-muted"
|
||||
aria-label={copied ? `${label} copied` : `Copy ${label}`}
|
||||
title={copied ? 'Copied' : 'Copy to clipboard'}
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function TabLabel({ icon, children }: { icon?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{icon && <span className="text-tertiary-foreground [&_svg]:size-3.5">{icon}</span>}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const tabTriggerClassName = 'h-full px-0 py-0 text-xs shadow-none data-[state=active]:shadow-none'
|
||||
|
||||
/**
|
||||
* Copyable prompt card. Compose one or more `<Prompt>` children; multiple
|
||||
* prompts render as tabs. A single prompt still shows the header + copy
|
||||
* button, with the title styled like an inactive tab (no underline).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PromptPanel>
|
||||
* <Prompt value="prompt" expandable>
|
||||
* <PromptTitle icon={<Sparkles />}>AI Prompt</PromptTitle>
|
||||
* <PromptCopy>Plain text copied to the clipboard</PromptCopy>
|
||||
* <PromptContent>Rich content shown in the panel</PromptContent>
|
||||
* </Prompt>
|
||||
* </PromptPanel>
|
||||
* ```
|
||||
*/
|
||||
function PromptPanel({ children, className }: PromptPanelProps) {
|
||||
const fallbackId = useId()
|
||||
const prompts = collectPrompts(children)
|
||||
const [activeTab, setActiveTab] = useState(prompts[0]?.value ?? fallbackId)
|
||||
const [shimmerEnabled, setShimmerEnabled] = useState(true)
|
||||
|
||||
if (prompts.length === 0) return null
|
||||
|
||||
const activePrompt = prompts.find((prompt) => prompt.value === activeTab) ?? prompts[0]
|
||||
const hasTabs = prompts.length > 1
|
||||
const dismissShimmer = () => setShimmerEnabled(false)
|
||||
|
||||
const header = (
|
||||
<div className="flex h-11 items-center justify-between border-b bg-surface-75 px-4">
|
||||
{hasTabs ? (
|
||||
<TabsList className="h-full gap-5 border-0">
|
||||
{prompts.map((prompt) => (
|
||||
<TabsTrigger key={prompt.value} value={prompt.value} className={tabTriggerClassName}>
|
||||
<TabLabel icon={prompt.icon}>{prompt.title}</TabLabel>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap border-b-2 border-transparent text-foreground-lighter',
|
||||
tabTriggerClassName
|
||||
)}
|
||||
>
|
||||
<TabLabel icon={prompts[0].icon}>{prompts[0].title}</TabLabel>
|
||||
</span>
|
||||
)}
|
||||
<CopyButton
|
||||
key={activePrompt.value}
|
||||
label={typeof activePrompt.title === 'string' ? activePrompt.title : 'content'}
|
||||
value={activePrompt.copyValue}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!hasTabs) {
|
||||
return (
|
||||
<div
|
||||
onFocusCapture={dismissShimmer}
|
||||
onPointerEnter={dismissShimmer}
|
||||
className={cn(
|
||||
'w-full overflow-hidden rounded-lg border bg-background shadow-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
<PromptBody prompt={prompts[0]} shimmerEnabled={shimmerEnabled} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
onFocusCapture={dismissShimmer}
|
||||
onPointerEnter={dismissShimmer}
|
||||
className={cn('w-full overflow-hidden rounded-lg border bg-background shadow-sm', className)}
|
||||
>
|
||||
{header}
|
||||
{prompts.map((prompt) => (
|
||||
<TabsContent key={prompt.value} value={prompt.value} className="m-0">
|
||||
<PromptBody prompt={prompt} shimmerEnabled={shimmerEnabled} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export { Prompt, PromptContent, PromptCopy, PromptPanel, PromptTitle }
|
||||
export type { PromptContentProps, PromptCopyProps, PromptPanelProps, PromptProps, PromptTitleProps }
|
||||
@@ -28,6 +28,7 @@ import { MetricsStackCards } from './markdown-schema/MetricsStackCards'
|
||||
import { NavData } from './markdown-schema/NavData'
|
||||
import { Panel } from './markdown-schema/Panel'
|
||||
import { Price } from './markdown-schema/Price'
|
||||
import { PromptPanel } from './markdown-schema/PromptPanel'
|
||||
import { RealtimeLimitsEstimator } from './markdown-schema/RealtimeLimitsEstimator'
|
||||
import { RegionsList, SmartRegionsList } from './markdown-schema/RegionsList'
|
||||
import { SharedData } from './markdown-schema/SharedData'
|
||||
@@ -174,6 +175,7 @@ const SCHEMA: ComponentSchema = {
|
||||
McpCiConfigBlock,
|
||||
McpConfigPanel,
|
||||
Price,
|
||||
...PromptPanel,
|
||||
GlassPanel: Panel,
|
||||
IconPanel: Panel,
|
||||
RealtimeLimitsEstimator,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { PromptPanel } from './PromptPanel'
|
||||
|
||||
function serializePrompt({
|
||||
title,
|
||||
copyValue,
|
||||
content,
|
||||
}: {
|
||||
title: string
|
||||
copyValue: string
|
||||
content: string
|
||||
}) {
|
||||
const children = [
|
||||
PromptPanel.PromptTitle({ children: title }),
|
||||
PromptPanel.PromptCopy({ children: copyValue }),
|
||||
PromptPanel.PromptContent({ children: content }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
|
||||
return PromptPanel.Prompt({ children })
|
||||
}
|
||||
|
||||
describe('PromptPanel markdown schema', () => {
|
||||
it('flattens prompts in order while preserving visible rich content', () => {
|
||||
const prompt = serializePrompt({
|
||||
title: 'AI Prompt',
|
||||
copyValue: 'clipboard-only prompt payload',
|
||||
content:
|
||||
'Install the CLI with `npm install -g supabase` and read the [CLI guide](/docs/guides/local-development/cli/getting-started).',
|
||||
})
|
||||
const cli = serializePrompt({
|
||||
title: 'CLI',
|
||||
copyValue: 'clipboard-only CLI payload',
|
||||
content: '```bash\nsupabase init\n```',
|
||||
})
|
||||
|
||||
const markdown = PromptPanel.PromptPanel({ children: [prompt, cli].join('\n\n') })
|
||||
|
||||
expect(markdown).toBe(`**AI Prompt**
|
||||
|
||||
Install the CLI with \`npm install -g supabase\` and read the [CLI guide](/docs/guides/local-development/cli/getting-started).
|
||||
|
||||
**CLI**
|
||||
|
||||
\`\`\`bash
|
||||
supabase init
|
||||
\`\`\``)
|
||||
expect(markdown).not.toContain('clipboard-only')
|
||||
})
|
||||
|
||||
it('omits empty titles without dropping prompt content', () => {
|
||||
const markdown = serializePrompt({
|
||||
title: ' ',
|
||||
copyValue: 'clipboard-only content',
|
||||
content: 'Visible content',
|
||||
})
|
||||
|
||||
expect(markdown).toBe('Visible content')
|
||||
})
|
||||
|
||||
it('registers every PromptPanel marker component', () => {
|
||||
expect(Object.keys(PromptPanel)).toEqual([
|
||||
'PromptPanel',
|
||||
'Prompt',
|
||||
'PromptTitle',
|
||||
'PromptCopy',
|
||||
'PromptContent',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
type HandlerContext = {
|
||||
children: string
|
||||
}
|
||||
|
||||
const preserveChildren = ({ children }: HandlerContext): string => children
|
||||
|
||||
const PromptTitle = ({ children }: HandlerContext): string => {
|
||||
const title = children.trim()
|
||||
return title ? `**${title}**` : ''
|
||||
}
|
||||
|
||||
// PromptCopy is clipboard-only metadata. PromptContent is the visible source of
|
||||
// truth and is preserved below, so including both would duplicate the prompt.
|
||||
const PromptCopy = (_context: HandlerContext): string => ''
|
||||
|
||||
export const PromptPanel = {
|
||||
PromptPanel: preserveChildren,
|
||||
Prompt: preserveChildren,
|
||||
PromptTitle,
|
||||
PromptCopy,
|
||||
PromptContent: preserveChildren,
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import HomePageCover from '~/components/HomePageCover'
|
||||
import { setupCommands } from '~/components/HomePageCover.constants'
|
||||
import { CodeBlock } from '~/features/ui/CodeBlock/CodeBlock'
|
||||
import { type PropsWithChildren } from 'react'
|
||||
|
||||
import HomePageCover from '~/components/HomePageCover'
|
||||
import { LayoutMainContent } from './DefaultLayout'
|
||||
import { SidebarSkeleton } from './MainSkeleton'
|
||||
|
||||
@@ -8,8 +10,19 @@ const HomeLayout = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<SidebarSkeleton hideSideNav>
|
||||
<article>
|
||||
<HomePageCover title="Supabase Documentation" />
|
||||
<LayoutMainContent>
|
||||
<HomePageCover
|
||||
title="Supabase Documentation"
|
||||
cliCode={
|
||||
<CodeBlock
|
||||
contents={setupCommands}
|
||||
lang="bash"
|
||||
lineNumbers={false}
|
||||
hideControls
|
||||
className="!-mx-4 !-my-3.5 !border-0 !rounded-none !bg-transparent [&_.code-content]:!px-4 [&_.code-content]:!py-3.5"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<LayoutMainContent className="max-w-7xl">
|
||||
<div className={['relative transition-all ease-out', 'duration-150 '].join(' ')}>
|
||||
<div className="prose max-w-none">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -296,3 +296,12 @@
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tw-shimmer {
|
||||
from {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
to {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
@@ -74,3 +74,109 @@
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Text shimmer (shadcn shimmer utility, inlined so we don't pull in the full
|
||||
* shadcn CSS package). Apply `shimmer` to text to draw attention.
|
||||
*/
|
||||
@property --shimmer-angle {
|
||||
syntax: '<angle>';
|
||||
inherits: true;
|
||||
initial-value: 20deg;
|
||||
}
|
||||
@property --shimmer-image {
|
||||
syntax: '*';
|
||||
inherits: false;
|
||||
}
|
||||
@property --shimmer-text-fill {
|
||||
syntax: '*';
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@utility shimmer {
|
||||
--_spread: var(--shimmer-spread, calc(3ch + 40px));
|
||||
--_base: currentColor;
|
||||
--_highlight: var(--shimmer-color, oklch(from currentColor l c h / calc(alpha* 0.2)));
|
||||
|
||||
background-image: var(
|
||||
--shimmer-image,
|
||||
linear-gradient(
|
||||
calc(90deg + var(--shimmer-angle)),
|
||||
var(--_base) calc(50% - var(--_spread)),
|
||||
color-mix(in oklch, var(--_highlight), var(--_base) 50%) calc(50% - var(--_spread) * 0.5),
|
||||
var(--_highlight) 50%,
|
||||
color-mix(in oklch, var(--_highlight), var(--_base) 50%) calc(50% + var(--_spread) * 0.5),
|
||||
var(--_base) calc(50% + var(--_spread))
|
||||
)
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
background-size: calc(200% + var(--_spread) * 2) 100%;
|
||||
background-position: 0 0;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: var(--shimmer-text-fill, transparent);
|
||||
animation: tw-shimmer var(--shimmer-duration, 2s) linear infinite;
|
||||
transition: -webkit-text-fill-color 0.5s ease;
|
||||
|
||||
/* Fade to solid text on dismiss — keep the gradient until fill is opaque */
|
||||
&[data-shimmer-fading] {
|
||||
-webkit-text-fill-color: currentColor;
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@variant dark {
|
||||
--_highlight: var(
|
||||
--shimmer-color,
|
||||
oklch(from currentColor max(0.8, calc(l + 0.4)) c h / calc(alpha + 0.4))
|
||||
);
|
||||
}
|
||||
|
||||
&:where([dir='rtl'], [dir='rtl'] *) {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
}
|
||||
|
||||
@utility shimmer-once {
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
|
||||
@utility shimmer-reverse {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
@utility shimmer-none {
|
||||
--shimmer-image: none;
|
||||
--shimmer-text-fill: currentColor;
|
||||
background-image: none;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
}
|
||||
|
||||
@utility shimmer-color-* {
|
||||
--shimmer-color: --value(--color-*, [color]);
|
||||
--shimmer-color: color-mix(
|
||||
in oklch,
|
||||
--value(--color-*, [color]) calc(--modifier(integer) * 1%),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
@utility shimmer-duration-* {
|
||||
--shimmer-duration: calc(--value(integer) * 1ms);
|
||||
}
|
||||
|
||||
@utility shimmer-spread-* {
|
||||
--shimmer-spread: calc(var(--spacing) * --value(integer));
|
||||
--shimmer-spread: --value([length], [percentage]);
|
||||
}
|
||||
|
||||
@utility shimmer-angle-* {
|
||||
--shimmer-angle: calc(--value(integer) * 1deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shimmer {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user