Docs
Next

Utilities

cn, LinkHelper, resolvePartClassName, resolveSamePageHash, getReactConfig, withTagProxy, animation context and useDeferredVideoControls.

On this page

The helpers the components are built from, exported for your own components. Most come from both packages; four are exported by @systhemaui/react only.

Export@systhemaui/react@systhemaui/next
cnyesyes (re-export)
resolvePartClassNameyesyes (re-export)
LinkHelperyesyes (own next/link twin)
getReactConfigyesyes (re-export)
useDeferredVideoControlsyesyes (re-export)
resolveSamePageHashyesno
withTagProxyyesno
DisableAnimationsContextyesno
useAnimationClassesyesno

The @systhemaui/next components import the last four from @systhemaui/react. To use them in a Next.js project, add @systhemaui/react to your own dependencies (pnpm does not let you import a package you don't depend on) and import them from there.

cnLink to this section

function cn(...classes: ClassValue[]): string

Joins class names with clsx (strings, arrays, objects of booleans) and resolves conflicting Tailwind CSS utilities with tailwind-merge, so the last one wins.

import { cn } from '@systhemaui/next'

cn('px-4 py-2', isActive && 'bg-layout-alternative', 'px-6')
// 'py-2 bg-layout-alternative px-6' when isActive is true

LinkHelperLink to this section

;<LinkHelper href="/about#team">Our team</LinkHelper>

The link every link-based Systhema component renders (Link, Button.a, Card.a, Chip.a, Icon.a, the header and footer items). In @systhemaui/react it is an <a>; in @systhemaui/next it is a next/link with all of its props. On top of a plain link it:

  • calls your onClick first; calling event.preventDefault() there opts out of everything below;
  • leaves new-tab, modified (Ctrl, Cmd, Shift, Alt) and non-primary clicks to the browser;
  • smooth-scrolls to the target of a hash link that points at the current page (#team, or /about#team while on /about), resolving the target by element id, and ignores a bare #;
  • adds rel="noopener noreferrer" to a target="_blank" link that has no rel.

Use it to give your own components the same link behaviour. The type is LinkHelperProps.

PropTypeDefaultDescription
…and all <a> attributes

resolveSamePageHashLink to this section

function resolveSamePageHash(href: string, currentPathname: string): string | null

Exported by @systhemaui/react only. Returns the fragment of href when it points at the current page, which is how LinkHelper decides to smooth-scroll:

hrefOn /aboutReturns
'#team'any page'team'
'#'any page''
'/about#team'/about'team'
'/about/?tab=1#team'/about'team'
'/contact#map'/aboutnull
'https://example.com/#a'any pagenull
'/about'/aboutnull

The fragment is returned as written; decode it with decodeURIComponent before looking up the element. An empty string means an anchor-only href that should do nothing. Query strings and trailing slashes are ignored when the paths are compared, and any URL with a scheme or starting with // counts as another page.

'use client'

import { resolveSamePageHash } from '@systhemaui/react'

export function scrollToSamePageTarget(href: string) {
  const hash = resolveSamePageHash(href, window.location.pathname)
  if (!hash) return false
  document.getElementById(decodeURIComponent(hash))?.scrollIntoView({ behavior: 'smooth' })
  return true
}

getReactConfig()Link to this section

function getReactConfig(): {
  enabled?: boolean
  animationClasses?: string
  transitionClasses?: string
  suppressHydrationWarning: true
}

Reads packages.react from systhema.config.ts. The components put animationClasses on everything that reveals on scroll and transitionClasses on everything that animates a state change. suppressHydrationWarning is always true: the server and the first browser render can see different class strings until SysthemaConfigClient hands the full config to the browser, and that mismatch is expected.

import { getReactConfig } from '@systhemaui/next'

export function Badge({ children }: { children: React.ReactNode }) {
  const { animationClasses, suppressHydrationWarning } = getReactConfig()
  return (
    <span className={animationClasses} suppressHydrationWarning={suppressHydrationWarning}>
      {children}
    </span>
  )
}

withTagProxyLink to this section

function withTagProxy<Result>(component: (props: never) => ReactNode): Result

Exported by @systhemaui/react only. Wraps a component that takes an as prop so that any property names a tag: Component.svg renders <Component as="svg" />. This is how ButtonIcon.<tag>, ChipIcon.<tag> and the accordion icon work. Each tag's component is created once and cached, so Component.span is the same component type on every render and React never remounts its subtree. Properties that already exist on the component are returned as they are.

You type the result yourself, usually as the component plus one entry per intrinsic element:

A tag-proxied component
import type { ComponentPropsWithoutRef, ElementType, JSX, ReactElement } from 'react'
import { withTagProxy } from '@systhemaui/react'

type BadgeProps = ComponentPropsWithoutRef<'span'> & { as?: ElementType }

function BadgeBase({ as: Component = 'span', className, ...props }: BadgeProps) {
  return <Component className={['chip', className].filter(Boolean).join(' ')} {...props} />
}

type BadgeType = typeof BadgeBase & {
  [Tag in keyof JSX.IntrinsicElements]: (props: JSX.IntrinsicElements[Tag]) => ReactElement
}

const Badge = withTagProxy<BadgeType>(BadgeBase)

export default function Demo() {
  return (
    <p>
      <Badge.strong>New</Badge.strong> <Badge.a href="#withtagproxy">Linked badge</Badge.a>
    </p>
  )
}

DisableAnimationsContextLink to this section

const DisableAnimationsContext: React.Context<boolean>

Exported by @systhemaui/react only. When an ancestor provides true, the components that read it (Image, Video and a nested MediaWrapper) leave out their scroll-animation classes. MediaWrapper provides it for its children, so the wrapper reveals as one unit instead of the media inside animating on top of it. Provide it in your own wrappers for the same effect:

'use client'

import type { ReactNode } from 'react'
import { DisableAnimationsContext } from '@systhemaui/react'

export function RevealAsOne({ children }: { children: ReactNode }) {
  return (
    <div className="aos animate-fadeinup">
      <DisableAnimationsContext.Provider value={true}>{children}</DisableAnimationsContext.Provider>
    </div>
  )
}

The context only reaches components that read it. Others (Heading, Paragraph, Card, …) take disableAnimation, or you suppress their reveals with the aos-disable-children class on the wrapper.

useAnimationClassesLink to this section

function useAnimationClasses(disableAnimation?: boolean): string | undefined

Exported by @systhemaui/react only. Returns your configured animationClasses, or undefined when disableAnimation is true or an ancestor provides DisableAnimationsContext with true. It is a client hook, and the drop-in replacement for !disableAnimation && getReactConfig().animationClasses inside a clsx or cn call:

'use client'

import type { ComponentPropsWithoutRef } from 'react'
import { cn } from '@systhemaui/next'
import { useAnimationClasses } from '@systhemaui/react'

type StatProps = ComponentPropsWithoutRef<'div'> & { disableAnimation?: boolean }

export function Stat({ className, disableAnimation, ...props }: StatProps) {
  const animationClasses = useAnimationClasses(disableAnimation)
  return <div className={cn('text-h2 color-heading', animationClasses, className)} {...props} />
}

resolvePartClassNameLink to this section

function resolvePartClassName(defaultClassName: ClassValue, override?: ClassValue): string

Folds a consumer's class override over a part's default class with cn, so a conflicting utility in the override replaces the default ('aspect-square' drops a default 'aspect-video'). The Posts components resolve every part this way. See Part-resolution helpers.

useDeferredVideoControlsLink to this section

function useDeferredVideoControls(options: DeferredVideoControlsOptions): DeferredVideoControls

The control deferral behind Video's deferControls, for your own <video> element. Pass { controls, deferControls, deferControlsLabel, autoPlay, onPlay, onClick, onKeyDown }; it returns { controls, activationProps }. Spread activationProps onto the <video> after your own props and use the returned controls. The native controls then appear on the first interaction instead of with the metadata, which keeps them from shifting the layout. See Loading and deferred controls.

PropTypeDefaultDescription
onPlay((event: SyntheticEvent<HTMLVideoElement, Event>) => void)-
onClick((event: MouseEvent<HTMLVideoElement, MouseEvent>) => void)-
onKeyDown((event: KeyboardEvent<HTMLVideoElement>) => void)-
role'button'-
tabIndexnumber-
aria-labelstring-