From f8447928b06349898f610c2e8884ae8e85fb1952 Mon Sep 17 00:00:00 2001 From: bestaxbot <300268469+bestaxbot@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:47:29 +0000 Subject: [PATCH] feat(bulma-ui): add Reveal component for scroll-triggered animations Adds a lightweight IntersectionObserver-backed wrapper that animates content into view as it scrolls into the viewport (fade/slide/zoom/flip, optional cascade stagger), with prefers-reduced-motion and SSR-safe behavior built in. Fixes #197 Co-authored-by: Alex Smith --- bulma-ui/src/components/Reveal.stories.tsx | 164 ++++++++ bulma-ui/src/components/Reveal.tsx | 256 ++++++++++++ .../src/components/__tests__/Reveal.test.tsx | 366 ++++++++++++++++++ bulma-ui/src/index.ts | 1 + bulma-ui/src/scss/components/_index.scss | 1 + bulma-ui/src/scss/components/_reveal.scss | 94 +++++ docs/docs/api/components/reveal.md | 195 ++++++++++ .../references/component-catalog.md | 3 +- 8 files changed, 1079 insertions(+), 1 deletion(-) create mode 100644 bulma-ui/src/components/Reveal.stories.tsx create mode 100644 bulma-ui/src/components/Reveal.tsx create mode 100644 bulma-ui/src/components/__tests__/Reveal.test.tsx create mode 100644 bulma-ui/src/scss/components/_reveal.scss create mode 100644 docs/docs/api/components/reveal.md diff --git a/bulma-ui/src/components/Reveal.stories.tsx b/bulma-ui/src/components/Reveal.stories.tsx new file mode 100644 index 000000000..3bb0efecf --- /dev/null +++ b/bulma-ui/src/components/Reveal.stories.tsx @@ -0,0 +1,164 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Reveal, RevealAnimation } from './Reveal'; +import { Card } from './Card'; +import { Section } from '../layout/Section'; +import { Columns } from '../columns/Columns'; +import { Column } from '../columns/Column'; +import { Title } from '../elements/Title'; +import { Content } from '../elements/Content'; +import { Box } from '../elements/Box'; + +const meta: Meta = { + title: 'Components/Reveal', + component: Reveal, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Animates its content into view as it scrolls into the viewport, using `IntersectionObserver`. Renders in its final, visible state during SSR and automatically skips the animation when the user prefers reduced motion.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + animation: { + control: 'select', + options: [ + 'fade', + 'fade-up', + 'fade-down', + 'slide-left', + 'slide-right', + 'zoom', + 'flip', + ], + description: + 'Animation style applied when the element enters the viewport.', + }, + delay: { + control: 'number', + description: 'Delay in milliseconds before the animation starts.', + }, + duration: { + control: 'number', + description: 'Animation duration in milliseconds.', + }, + threshold: { + control: 'number', + description: + 'Fraction (0-1) of the element that must be visible to trigger the reveal.', + }, + once: { + control: 'boolean', + description: + 'Animate only the first time the element enters the viewport.', + }, + cascade: { + control: 'boolean', + description: + 'Stagger direct children with an incrementing delay instead of animating this element as a single block.', + }, + cascadeInterval: { + control: 'number', + description: + "Milliseconds added to each successive child's delay when `cascade` is set.", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + animation: 'fade-up', + }, + render: args => ( + + + Reveal me + + This box fades up into view. Since it is already visible in the + Storybook canvas, it appears revealed immediately — scroll it out of + view and back in the "Scroll To Reveal" story to see the effect + trigger. + + + + ), +}; + +const ANIMATIONS: RevealAnimation[] = [ + 'fade', + 'fade-up', + 'fade-down', + 'slide-left', + 'slide-right', + 'zoom', + 'flip', +]; + +export const Animations: Story = { + render: () => ( + + {ANIMATIONS.map(animation => ( + + + + {animation} + + + + ))} + + ), +}; + +export const AsSection: Story = { + render: () => ( + + Why bestax + + Rendered as a `Section` via the `as` prop instead of the default `div`. + + + ), +}; + +export const Cascade: Story = { + args: { + animation: 'fade-up', + cascade: true, + cascadeInterval: 100, + }, + render: args => ( + + {['Fast', 'Accessible', 'Themeable'].map(feature => ( + + + {feature} + + + ))} + + ), +}; + +export const ScrollToReveal: Story = { + render: () => ( + <> + Scroll down to see the section fade up into view. +
+ + + Now you see me + + This box only animates in once it crosses the visibility threshold. + + + +
+ + ), +}; diff --git a/bulma-ui/src/components/Reveal.tsx b/bulma-ui/src/components/Reveal.tsx new file mode 100644 index 000000000..cd5b74215 --- /dev/null +++ b/bulma-ui/src/components/Reveal.tsx @@ -0,0 +1,256 @@ +import React, { + Children, + cloneElement, + isValidElement, + useEffect, + useState, +} from 'react'; +import { + classNames, + prefixedClassNames, + usePrefixedClassNames, +} from '../helpers/classNames'; +import { useClassPrefix } from '../helpers/Config'; +import { useBulmaClasses, BulmaClassesProps } from '../helpers/useBulmaClasses'; + +/** + * Animation styles available for the Reveal component. + */ +export type RevealAnimation = + | 'fade' + | 'fade-up' + | 'fade-down' + | 'slide-left' + | 'slide-right' + | 'zoom' + | 'flip'; + +/** + * Props for the Reveal component. + * + * @property {RevealAnimation} [animation] - Animation style used when the element enters the viewport. Default: 'fade-up'. + * @property {number} [delay] - Delay in milliseconds before the animation starts. Default: 0. + * @property {number} [duration] - Animation duration in milliseconds. Default: 600. + * @property {number} [threshold] - Fraction (0-1) of the element that must be visible to trigger the reveal. Values are clamped to the 0-1 range and fall back to 0.15 when not a finite number. Default: 0.15. + * @property {boolean} [once] - Animate only the first time the element enters the viewport; if `false`, it re-animates on every entry/exit. Default: true. + * @property {React.ElementType} [as] - Element or component to render as. Default: 'div'. When `as` is a plain intrinsic tag (e.g. `'section'`), your `className`, `style`, and Bulma helper classes plus everything in `...rest` all land on that single element. When `as` is a component (e.g. `Section`, `Card`), scroll detection needs a real DOM node with a ref, so `Reveal` wraps it in an observed `div`: `className`/`style`/helper classes go on that wrapper `div`, while `...rest` (`id`, `aria-*`, `data-*`, event handlers) is forwarded to the inner component. + * @property {boolean} [cascade] - Stagger direct children with an incrementing delay instead of animating this element as a single block. + * @property {number} [cascadeInterval] - Milliseconds added to each successive child's delay when `cascade` is set. Default: 80. + * @property {React.ReactNode} [children] - Content to reveal. + */ +export interface RevealProps + extends Omit, 'color'>, BulmaClassesProps { + animation?: RevealAnimation; + delay?: number; + duration?: number; + threshold?: number; + once?: boolean; + as?: React.ElementType; + cascade?: boolean; + cascadeInterval?: number; + children?: React.ReactNode; +} + +/** + * Detects the user's `prefers-reduced-motion` preference. Always `false` on + * the server and on the very first client render, so SSR output and the + * initial hydration pass always agree. + * + * @function + * @returns {boolean} Whether the user has requested reduced motion. + */ +function usePrefersReducedMotion(): boolean { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return undefined; + + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + // eslint-disable-next-line react-hooks/set-state-in-effect -- reads the live media query on mount + setPrefersReducedMotion(mediaQuery.matches); + + const handleChange = (event: MediaQueryListEvent) => { + setPrefersReducedMotion(event.matches); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + return prefersReducedMotion; +} + +/** + * Reveal component that animates its content into view as it scrolls into + * the viewport, backed by `IntersectionObserver`. + * + * Renders in its final, visible state during SSR and on the first client + * render, so content is never hidden if JavaScript never runs (crawlers, + * disabled JS). Automatically skips the animation (renders the final state + * immediately) when the user prefers reduced motion. + * + * @function + * @param {RevealProps} props - Props for the Reveal component. + * @returns {JSX.Element} The rendered reveal wrapper. + * + * @example + * // Fade a section up into view + * + * Why Grass Doctor + * + * + * @example + * // Stagger a set of cards as they enter the viewport + * + * {services.map(service => ( + * {service.name} + * ))} + * + */ +export const Reveal: React.FC = ({ + animation = 'fade-up', + delay = 0, + duration = 600, + threshold = 0.15, + once = true, + as: Component = 'div', + cascade = false, + cascadeInterval = 80, + className, + style, + children, + ...props +}) => { + const { bulmaHelperClasses, rest } = useBulmaClasses(props); + const classPrefix = useClassPrefix(); + const prefersReducedMotion = usePrefersReducedMotion(); + const [node, setNode] = useState(null); + const [isMounted, setIsMounted] = useState(false); + const [isRevealed, setIsRevealed] = useState(false); + + // IntersectionObserver throws a RangeError for NaN or thresholds outside + // 0-1, so clamp valid numbers and fall back to the default for anything else. + const observerThreshold = Number.isFinite(threshold) + ? Math.min(Math.max(threshold, 0), 1) + : 0.15; + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- flips to true once mounted on the client so SSR/first-paint markup matches the server + setIsMounted(true); + }, []); + + useEffect(() => { + if (prefersReducedMotion) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reduced motion skips straight to the revealed state + setIsRevealed(true); + return undefined; + } + + if (typeof IntersectionObserver === 'undefined') { + setIsRevealed(true); + return undefined; + } + + // The ref callback attaches after this effect's first pass; it re-runs + // once `node` becomes available since it's a dependency. + if (!node) return undefined; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsRevealed(true); + if (once) observer.unobserve(node); + } else if (!once) { + setIsRevealed(false); + } + }, + { threshold: observerThreshold } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [node, prefersReducedMotion, once, observerThreshold]); + + // Only apply the hidden/animating state once mounted on the client with + // motion allowed; otherwise render the final, visible state (SSR-safe). + const isAnimated = isMounted && !prefersReducedMotion; + const revealed = !isAnimated || isRevealed; + + const itemAnimationClasses = prefixedClassNames(classPrefix, { + [`reveal-${animation}`]: isAnimated, + 'is-revealed': revealed, + }); + + const combinedClasses = classNames( + usePrefixedClassNames('reveal', { 'is-cascade': cascade }), + !cascade && itemAnimationClasses, + bulmaHelperClasses, + className + ); + + const wrapperStyle: React.CSSProperties = cascade + ? { ...style } + : { + ...style, + transitionDelay: `${delay}ms`, + transitionDuration: `${duration}ms`, + }; + + const content = cascade + ? Children.toArray(children).map((child, index) => { + const itemStyle: React.CSSProperties = { + transitionDelay: `${delay + index * cascadeInterval}ms`, + transitionDuration: `${duration}ms`, + }; + + if ( + isValidElement<{ className?: string; style?: React.CSSProperties }>( + child + ) + ) { + return cloneElement(child, { + // Children.toArray always assigns a key, generating one from + // the position when the element doesn't already have one. + key: child.key, + className: classNames(itemAnimationClasses, child.props.className), + style: { ...itemStyle, ...child.props.style }, + }); + } + + return ( + + {child} + + ); + }) + : children; + + // Scroll observation needs a real DOM node. Intrinsic tags ('div', + // 'section', ...) always accept a ref directly. A custom component passed + // via `as` (Section, Card, ...) is rendered by this library as a plain + // React.FC with no ref forwarding, so the ref (and the animation classes + // that depend on it) go on a plain wrapper `div` instead, with `Component` + // rendered inside it. + if (typeof Component === 'string') { + // A plain createElement call sidesteps the combinatorial JSX prop types + // for `ref` across every possible intrinsic tag `as` could be. + return React.createElement( + Component, + { + ...rest, + ref: setNode, + className: combinedClasses, + style: wrapperStyle, + }, + content + ); + } + + return ( +
+ {content} +
+ ); +}; + +export default Reveal; diff --git a/bulma-ui/src/components/__tests__/Reveal.test.tsx b/bulma-ui/src/components/__tests__/Reveal.test.tsx new file mode 100644 index 000000000..469ae7f7b --- /dev/null +++ b/bulma-ui/src/components/__tests__/Reveal.test.tsx @@ -0,0 +1,366 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import '@testing-library/jest-dom'; +import { Reveal } from '../Reveal'; +import { ConfigProvider } from '../../helpers/Config'; + +type Listener = (event: { matches: boolean }) => void; + +class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = []; + callback: IntersectionObserverCallback; + options?: IntersectionObserverInit; + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); + takeRecords = jest.fn(() => []); + root = null; + rootMargin = ''; + thresholds: ReadonlyArray = []; + + constructor( + callback: IntersectionObserverCallback, + options?: IntersectionObserverInit + ) { + this.callback = callback; + this.options = options; + MockIntersectionObserver.instances.push(this); + } + + trigger(isIntersecting: boolean) { + act(() => { + this.callback( + [{ isIntersecting } as IntersectionObserverEntry], + this as unknown as IntersectionObserver + ); + }); + } +} + +function mockMatchMedia(matches: boolean) { + const listeners: Listener[] = []; + const mql = { + matches, + media: '(prefers-reduced-motion: reduce)', + addEventListener: (_event: string, cb: Listener) => listeners.push(cb), + removeEventListener: jest.fn(), + }; + window.matchMedia = jest.fn().mockReturnValue(mql); + return { mql, listeners }; +} + +describe('Reveal', () => { + const originalIntersectionObserver = global.IntersectionObserver; + const originalMatchMedia = window.matchMedia; + + beforeEach(() => { + MockIntersectionObserver.instances = []; + global.IntersectionObserver = + MockIntersectionObserver as unknown as typeof IntersectionObserver; + }); + + afterEach(() => { + global.IntersectionObserver = originalIntersectionObserver; + window.matchMedia = originalMatchMedia; + }); + + describe('rendering', () => { + it('renders children inside a div by default', () => { + const { container } = render( + +

Hello

+
+ ); + expect(screen.getByText('Hello')).toBeInTheDocument(); + expect(container.querySelector('div.reveal')).toBeInTheDocument(); + }); + + it('renders as a custom element via the `as` prop', () => { + const { container } = render( + + Content + + ); + expect(container.querySelector('section.reveal')).toBeInTheDocument(); + }); + + it('renders as a custom, non-forwardRef component via the `as` prop', () => { + // Modeled on this library's own components (Section, Card, ...), which + // are plain function components with no ref forwarding. + const Custom: React.FC<{ + children?: React.ReactNode; + 'data-testid'?: string; + }> = ({ children, ...rest }) =>
{children}
; + const { container } = render( + + Content + + ); + expect(screen.getByTestId('custom').tagName).toBe('ARTICLE'); + // Falls back to an internal wrapper div for scroll observation, since + // Custom can't accept a ref. + expect( + container.querySelector('div.reveal.reveal-fade') + ).toContainElement(screen.getByTestId('custom')); + }); + + it('applies the animation class once mounted', () => { + const { container } = render(Content); + expect(container.querySelector('.reveal-fade')).toBeInTheDocument(); + }); + + it('applies default fade-up animation class', () => { + const { container } = render(Content); + expect(container.querySelector('.reveal-fade-up')).toBeInTheDocument(); + }); + + it('merges a custom className and passes through helper props', () => { + const { container } = render( + + Content + + ); + const el = container.querySelector('.reveal'); + expect(el).toHaveClass('custom-class'); + expect(el).toHaveClass('m-4'); + }); + + it('applies duration and delay as inline transition styles', () => { + const { container } = render( + + Content + + ); + const el = container.querySelector('.reveal') as HTMLElement; + expect(el.style.transitionDuration).toBe('800ms'); + expect(el.style.transitionDelay).toBe('200ms'); + }); + + it('applies classPrefix from ConfigProvider', () => { + const { container } = render( + + Content + + ); + const el = container.querySelector('.bulma-reveal'); + expect(el).toBeInTheDocument(); + expect(el).toHaveClass('bulma-reveal-fade'); + expect(el).not.toHaveClass('reveal'); + }); + }); + + describe('IntersectionObserver behavior', () => { + it('is not revealed before intersecting', () => { + const { container } = render(Content); + expect(container.querySelector('.reveal')).not.toHaveClass('is-revealed'); + }); + + it('observes the rendered node with the given threshold', () => { + render(Content); + const instance = MockIntersectionObserver.instances[0]; + expect(instance.observe).toHaveBeenCalled(); + expect(instance.options).toEqual({ threshold: 0.4 }); + }); + + it('clamps a threshold above 1 down to 1', () => { + render(Content); + const instance = MockIntersectionObserver.instances[0]; + expect(instance.options).toEqual({ threshold: 1 }); + }); + + it('clamps a negative threshold up to 0', () => { + render(Content); + const instance = MockIntersectionObserver.instances[0]; + expect(instance.options).toEqual({ threshold: 0 }); + }); + + it('falls back to the default threshold for a non-finite value', () => { + render(Content); + const instance = MockIntersectionObserver.instances[0]; + expect(instance.options).toEqual({ threshold: 0.15 }); + }); + + it('adds is-revealed once the element intersects', () => { + const { container } = render(Content); + const instance = MockIntersectionObserver.instances[0]; + + instance.trigger(true); + + expect(container.querySelector('.reveal')).toHaveClass('is-revealed'); + }); + + it('unobserves after the first reveal by default (once=true)', () => { + render(Content); + const instance = MockIntersectionObserver.instances[0]; + + instance.trigger(true); + + expect(instance.unobserve).toHaveBeenCalled(); + }); + + it('stays revealed on exit when once=true (default)', () => { + const { container } = render(Content); + const instance = MockIntersectionObserver.instances[0]; + + instance.trigger(true); + instance.trigger(false); + + expect(container.querySelector('.reveal')).toHaveClass('is-revealed'); + }); + + it('toggles is-revealed on exit when once=false', () => { + const { container } = render( + + Content + + ); + const instance = MockIntersectionObserver.instances[0]; + + instance.trigger(true); + expect(container.querySelector('.reveal')).toHaveClass('is-revealed'); + + instance.trigger(false); + expect(container.querySelector('.reveal')).not.toHaveClass('is-revealed'); + expect(instance.unobserve).not.toHaveBeenCalled(); + }); + + it('reveals immediately when IntersectionObserver is unavailable', () => { + // @ts-expect-error simulating an environment without IntersectionObserver + delete global.IntersectionObserver; + + const { container } = render(Content); + expect(container.querySelector('.reveal')).toHaveClass('is-revealed'); + }); + + it('still observes and reveals when `as` is a non-forwardRef component', () => { + // Regression test: a plain function component (like this library's own + // Section/Card) can't accept a ref directly, so Reveal must fall back + // to its internal wrapper div rather than silently never observing. + const Plain: React.FC<{ children?: React.ReactNode }> = ({ + children, + }) =>

{children}

; + + const { container } = render( + + Content + + ); + const instance = MockIntersectionObserver.instances[0]; + expect(instance.observe).toHaveBeenCalled(); + + instance.trigger(true); + + expect(container.querySelector('.reveal')).toHaveClass('is-revealed'); + }); + }); + + describe('prefers-reduced-motion', () => { + it('renders the final state immediately and skips the animation class', () => { + mockMatchMedia(true); + + const { container } = render(Content); + const el = container.querySelector('.reveal') as HTMLElement; + + expect(el).toHaveClass('is-revealed'); + expect(el).not.toHaveClass('reveal-fade'); + }); + + it('reacts to a change event from the media query', () => { + const { listeners } = mockMatchMedia(false); + + const { container } = render(Content); + expect(container.querySelector('.reveal')).toHaveClass('reveal-fade'); + + act(() => { + listeners.forEach(listener => listener({ matches: true })); + }); + + expect(container.querySelector('.reveal')).not.toHaveClass('reveal-fade'); + }); + }); + + describe('cascade', () => { + it('clones each direct child with animation classes and a staggered delay', () => { + const { container } = render( + +
A
+
B
+
C
+
+ ); + + const item0 = screen.getByTestId('item-0'); + const item1 = screen.getByTestId('item-1'); + const item2 = screen.getByTestId('item-2'); + + expect(item0).toHaveClass('reveal-fade'); + expect(item0.style.transitionDelay).toBe('0ms'); + expect(item1.style.transitionDelay).toBe('50ms'); + expect(item2.style.transitionDelay).toBe('100ms'); + [item0, item1, item2].forEach(item => { + expect(item.style.transitionDuration).toBe('300ms'); + }); + + // The container itself is not animated as a single block. + expect(container.querySelector('.reveal')).not.toHaveClass('reveal-fade'); + }); + + it('reveals cascaded children together once the container intersects', () => { + render( + +
A
+
B
+
+ ); + const instance = MockIntersectionObserver.instances[0]; + + instance.trigger(true); + + expect(screen.getByTestId('item-0')).toHaveClass('is-revealed'); + expect(screen.getByTestId('item-1')).toHaveClass('is-revealed'); + }); + + it('wraps non-element children (e.g. text nodes) in a span', () => { + const { container } = render( + + Plain text + + ); + const span = container.querySelector('span'); + expect(span).toBeInTheDocument(); + expect(span).toHaveClass('reveal-fade'); + expect(span).toHaveTextContent('Plain text'); + }); + + it('merges cascaded child className and style rather than overwriting them', () => { + render( + +
+ A +
+
+ ); + const item = screen.getByTestId('item-0'); + expect(item).toHaveClass('existing-class'); + expect(item).toHaveClass('reveal-fade'); + expect(item.style.color).toBe('red'); + }); + }); + + describe('SSR safety', () => { + it('renders the final, visible markup with no animation class before hydration', () => { + const html = renderToStaticMarkup( + +

SSR content

+
+ ); + expect(html).toContain('SSR content'); + expect(html).not.toContain('reveal-fade-up'); + }); + }); +}); diff --git a/bulma-ui/src/index.ts b/bulma-ui/src/index.ts index aa943777a..7f9b8f403 100644 --- a/bulma-ui/src/index.ts +++ b/bulma-ui/src/index.ts @@ -19,6 +19,7 @@ export * from './components/Sidebar'; export * from './components/Toast'; export * from './components/Dialog'; export * from './components/Carousel'; +export * from './components/Reveal'; export * from './elements/Block'; export * from './elements/Box'; diff --git a/bulma-ui/src/scss/components/_index.scss b/bulma-ui/src/scss/components/_index.scss index 2ecf15018..bf0bb39b0 100644 --- a/bulma-ui/src/scss/components/_index.scss +++ b/bulma-ui/src/scss/components/_index.scss @@ -11,3 +11,4 @@ @use 'dialog'; @use 'carousel'; @use 'tabs'; +@use 'reveal'; diff --git a/bulma-ui/src/scss/components/_reveal.scss b/bulma-ui/src/scss/components/_reveal.scss new file mode 100644 index 000000000..404a4d064 --- /dev/null +++ b/bulma-ui/src/scss/components/_reveal.scss @@ -0,0 +1,94 @@ +// Reveal component styles +// Scroll-triggered "animate into view" wrapper, driven by IntersectionObserver +// toggling the `is-revealed` class; see Reveal.tsx. +@use 'bulma/sass/utilities/initial-variables' as iv; +@use 'bulma/sass/utilities/css-variables' as cv; +@use '../mixins' as *; + +// Reveal-specific SCSS variables +$reveal-offset: 24px !default; +$reveal-offset-large: 64px !default; +$reveal-scale: 0.92 !default; +$reveal-flip-angle: -15deg !default; +$reveal-duration: cv.getVar('duration') !default; +$reveal-easing: cv.getVar('easing') !default; + +.#{iv.$class-prefix}reveal { + @include cv.register-vars( + ( + 'reveal-offset': #{$reveal-offset}, + 'reveal-offset-large': #{$reveal-offset-large}, + 'reveal-scale': #{$reveal-scale}, + 'reveal-flip-angle': #{$reveal-flip-angle}, + 'reveal-duration': #{$reveal-duration}, + 'reveal-easing': #{$reveal-easing}, + ) + ); +} + +// Shared transition applied to whichever element (the Reveal wrapper itself, +// or each cascaded child) carries a `reveal-*` animation class. +.#{iv.$class-prefix}reveal-fade, +.#{iv.$class-prefix}reveal-fade-up, +.#{iv.$class-prefix}reveal-fade-down, +.#{iv.$class-prefix}reveal-slide-left, +.#{iv.$class-prefix}reveal-slide-right, +.#{iv.$class-prefix}reveal-zoom, +.#{iv.$class-prefix}reveal-flip { + opacity: 0; + transition-property: opacity, transform; + transition-duration: cv.getVar('reveal-duration'); + transition-timing-function: cv.getVar('reveal-easing'); +} + +.#{iv.$class-prefix}reveal-fade-up { + transform: translateY(cv.getVar('reveal-offset')); +} + +.#{iv.$class-prefix}reveal-fade-down { + transform: translateY(calc(-1 * cv.getVar('reveal-offset'))); +} + +.#{iv.$class-prefix}reveal-slide-left { + transform: translateX(cv.getVar('reveal-offset-large')); +} + +.#{iv.$class-prefix}reveal-slide-right { + transform: translateX(calc(-1 * cv.getVar('reveal-offset-large'))); +} + +.#{iv.$class-prefix}reveal-zoom { + transform: scale(cv.getVar('reveal-scale')); +} + +.#{iv.$class-prefix}reveal-flip { + transform: perspective(600px) rotateX(cv.getVar('reveal-flip-angle')); +} + +// Final, visible state — reached once the element has scrolled into view. +.#{iv.$class-prefix}reveal-fade.#{iv.$class-prefix}is-revealed, +.#{iv.$class-prefix}reveal-fade-up.#{iv.$class-prefix}is-revealed, +.#{iv.$class-prefix}reveal-fade-down.#{iv.$class-prefix}is-revealed, +.#{iv.$class-prefix}reveal-slide-left.#{iv.$class-prefix}is-revealed, +.#{iv.$class-prefix}reveal-slide-right.#{iv.$class-prefix}is-revealed, +.#{iv.$class-prefix}reveal-zoom.#{iv.$class-prefix}is-revealed, +.#{iv.$class-prefix}reveal-flip.#{iv.$class-prefix}is-revealed { + opacity: 1; + transform: none; +} + +// Respect the user's OS-level motion preference: skip the animation and show +// the final state immediately, regardless of scroll position. +@media (prefers-reduced-motion: reduce) { + .#{iv.$class-prefix}reveal-fade, + .#{iv.$class-prefix}reveal-fade-up, + .#{iv.$class-prefix}reveal-fade-down, + .#{iv.$class-prefix}reveal-slide-left, + .#{iv.$class-prefix}reveal-slide-right, + .#{iv.$class-prefix}reveal-zoom, + .#{iv.$class-prefix}reveal-flip { + transition: none; + opacity: 1; + transform: none; + } +} diff --git a/docs/docs/api/components/reveal.md b/docs/docs/api/components/reveal.md new file mode 100644 index 000000000..46d51dff3 --- /dev/null +++ b/docs/docs/api/components/reveal.md @@ -0,0 +1,195 @@ +--- +title: Reveal +sidebar_label: Reveal +--- + +# Reveal + +## Overview + +The `Reveal` component animates its content into view as it scrolls into the viewport, backed by `IntersectionObserver`. It's a lightweight, CSS-driven wrapper for "fade/slide/zoom in on scroll" effects on landing pages — no animation runtime dependency required. + +Accessibility and progressive enhancement are built in, not opt-in: + +- Automatically **skips the animation** (renders the final, visible state immediately) when the + user's OS requests `prefers-reduced-motion: reduce`. +- Renders the final, visible state during **server-side rendering** and on the very first client + render, so content is never hidden from crawlers or users if JavaScript never runs. +- Falls back to the visible state immediately in environments without `IntersectionObserver`. + +--- + +## Import + +```tsx +import { Reveal } from '@allxsmith/bestax-bulma'; +``` + +--- + +## Props + +| Prop | Type | Default | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `animation` | `'fade'` \| `'fade-up'` \| `'fade-down'` \| `'slide-left'` \| `'slide-right'` \| `'zoom'` \| `'flip'` | `'fade-up'` | Animation style applied when the element enters the viewport. | +| `delay` | `number` | `0` | Delay in milliseconds before the animation starts. | +| `duration` | `number` | `600` | Animation duration in milliseconds. | +| `threshold` | `number` | `0.15` | Fraction (0-1) of the element that must be visible to trigger the reveal. Clamped to the 0-1 range; non-finite values fall back to `0.15`. | +| `once` | `boolean` | `true` | Animate only the first time the element enters the viewport. If `false`, it re-animates on every entry/exit. | +| `as` | `React.ElementType` | `'div'` | Element or component to render as. | +| `cascade` | `boolean` | `false` | Stagger direct children with an incrementing delay instead of animating this element as a single block. | +| `cascadeInterval` | `number` | `80` | Milliseconds added to each successive child's delay when `cascade` is set. | +| `children` | `React.ReactNode` | — | Content to reveal. | +| `className` | `string` | — | Additional CSS classes. | +| ... | All standard HTML and Bulma helper props | | (See [Helper Props](../helpers/usebulmaclasses)) | + +--- + +## Usage + +### Basic Reveal + +Fades a block up into view as it scrolls into the viewport. + +```tsx live +function example() { + return ( + + + Why bestax + This box fades up into view once it's on screen. + + + ); +} +``` + +### Rendering as a different element + +Use `as` to render the wrapper as a different tag or component, e.g. `Section`. + +```tsx live +function example() { + return ( + + Why Grass Doctor + Rendered as a `Section` instead of the default `div`. + + ); +} +``` + +:::note Where your props land with a component `as` + +When `as` is a plain intrinsic tag (e.g. `as="section"`), your `className`, `style`, Bulma +helper classes, and everything else (`id`, `aria-*`, `data-*`, event handlers) all land on that +single rendered element. + +When `as` is a **component** (e.g. `as={Section}`), scroll detection needs a real DOM node it +can attach a ref to, which library components like `Section`/`Card` don't forward. `Reveal` +therefore wraps the component in an observed `div`: your `className`, `style`, and helper +classes go on that wrapper `div`, while the remaining props (`id`, `aria-*`, `data-*`, event +handlers) are forwarded to the inner component. A combined selector like `#hero.highlight` that +assumes both `id` and `className` sit on the same element won't match in that case. + +For the same reason, avoid Bulma layout primitives that must be a **direct child** of their +container — `Column` (inside `Columns`) or `Cell` (inside `Grid`) — as the `as` component: the +observed wrapper `div` sits between the container and the primitive and breaks the layout. Reveal +the surrounding container instead, or use `cascade` to animate the primitives as children. + +::: + +### Animation styles + +`animation` accepts `'fade'`, `'fade-up'`, `'fade-down'`, `'slide-left'`, `'slide-right'`, +`'zoom'`, and `'flip'`. + +```tsx live +function example() { + return ( + + + + + zoom + + + + + + + flip + + + + + + + slide-left + + + + + ); +} +``` + +### Staggered (cascade) children + +Set `cascade` to stagger **direct children** of `Reveal` with an incrementing +`transitionDelay` (`cascadeInterval` milliseconds apart), rather than animating the wrapper as a +single block. Pass the individual items to stagger (e.g. `Card`s) directly as children, rather +than a single nested layout component. Use Bulma helper props like `display="flex"` for layout +instead of inline `style`. + +```tsx live +function example() { + return ( + + {['Fast', 'Accessible', 'Themeable'].map(feature => ( + + + {feature} + + + ))} + + ); +} +``` + +### Re-animating on every entry + +By default, `Reveal` only animates the first time it enters the viewport (`once`). Set +`once={false}` to have it re-animate every time it scrolls in and out of view. + +```tsx live +function example() { + return ( + + + Scroll me out of view and back in to see this animate again. + + + ); +} +``` + +--- + +## Accessibility + +- `Reveal` never removes content from the accessibility tree — it only animates `opacity` and + `transform`, so screen reader users always have access to the content regardless of scroll + position. +- When the user's OS is set to reduce motion (`prefers-reduced-motion: reduce`), `Reveal` skips + the animation entirely and renders the final, visible state immediately. +- During server-side rendering and the first client render (before hydration effects run), + `Reveal` renders the final, visible state — content is never hidden if JavaScript fails to + load, ensuring crawlers and no-JS visitors always see the full page. diff --git a/skills/bestax-custom-component/references/component-catalog.md b/skills/bestax-custom-component/references/component-catalog.md index b86537a86..2466d7a76 100644 --- a/skills/bestax-custom-component/references/component-catalog.md +++ b/skills/bestax-custom-component/references/component-catalog.md @@ -19,7 +19,7 @@ instead of hand-writing markup. - Raw `*Base` form exports (`InputBase`, `SelectBase`, `TextAreaBase`, …) are escape-hatch variants of the convenience wrappers above them; see the Form docs. -81 documented components. Generated from the API docs — every exported +82 documented components. Generated from the API docs — every exported component is guaranteed to appear (the generator fails if one lacks an API page). ## Elements @@ -70,6 +70,7 @@ component is guaranteed to appear (the generator fails if one lacks an API page) - [Navbar](https://bestax.io/docs/api/components/navbar) — The `Navbar` component implements Bulma's powerful, responsive navigation bar for your Bulma React UI. - [Pagination](https://bestax.io/docs/api/components/pagination) — The `Pagination` component provides a flexible, composable Bulma pagination navigation for your Bulma React UI. - [Panel](https://bestax.io/docs/api/components/panel) — The `Panel` component implements Bulma's versatile panel block for React. +- [Reveal](https://bestax.io/docs/api/components/reveal) — The `Reveal` component animates its content into view as it scrolls into the viewport, backed by `IntersectionObserver`. - [Sidebar](https://bestax.io/docs/api/components/sidebar) — The `Sidebar` component provides a slide-out navigation panel that appears from the left or right side of the screen. - [Steps](https://bestax.io/docs/api/components/steps) — The `Steps` component provides a multi-step progress indicator for wizard flows, checkout processes, or any multi-step workflow. - [Tabs](https://bestax.io/docs/api/components/tabs) — The `Tabs` component provides flexible and fully-featured Bulma tab navigation for your Bulma React UI.