Skip to content

Last updated:

CSS Methodology

CPR follows a strict methodology for where and how CSS is written. The goal is predictability: every developer should know exactly where to look for a style rule and how to add a new one.

Tailwind Layers

All global CSS lives in app/assets/css/main.css and is organized using Tailwind's @layer directive. Layers control specificity and purge behavior.

@layer base -- Global defaults

Used only for element-level defaults that apply everywhere. CPR defines exactly one base rule:

css
@layer base {
  body {
    @apply bg-gray-50 text-gray-900;
  }
}

When to add to @layer base: Almost never. Only for global element resets (e.g., setting a default font or link color site-wide). Prefer component-level styling in all other cases.

@layer components -- Reusable class patterns

Contains classes that represent a complete visual component. These are available globally and can be overridden by utilities.

css
@layer components {
  .btn-primary {
    @apply bg-primary-600 text-white px-4 py-2 rounded-lg
           hover:bg-primary-700 transition-colors
           disabled:opacity-50 disabled:cursor-not-allowed;
  }

  .btn-secondary {
    @apply bg-gray-200 text-gray-800 px-4 py-2 rounded-lg
           hover:bg-gray-300 transition-colors;
  }

  .input-field {
    @apply w-full px-4 py-2 border border-gray-300 rounded-lg
           focus:ring-2 focus:ring-primary-500 focus:border-transparent
           outline-none transition-all;
  }

  .card {
    @apply bg-white rounded-lg shadow-md p-6;
  }
}

When to add to @layer components: When you have a utility combination that is used in 3+ places across different components and cannot be extracted into a shared Vue component. Prefer Vue components over CSS component classes when the pattern includes structure (HTML) in addition to styling.

Non-layer CSS -- Escape hatch

CSS that sits outside @layer has higher specificity than layered styles. CPR uses this only for pseudo-element styling that Tailwind cannot express:

css
.custom-scrollbar::-webkit-scrollbar {
  width: 5px;
}
.custom-scrollbar::-webkit-scrollbar-track {
  background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
  background: #e2e8f0;
  border-radius: 10px;
}

When to use non-layer CSS: Only when targeting pseudo-elements (::-webkit-scrollbar, ::placeholder, ::selection) or when you need a CSS feature that Tailwind does not support as a utility.

When to Use @apply vs Inline Utilities

ScenarioApproachExample
Styling a single element in a templateInline utilities<div class="bg-white rounded-xl p-4">
A class used in 3+ templates@apply in @layer components.card { @apply bg-white rounded-lg shadow-md p-6; }
Variant logic (size, color, state)Computed class string in <script setup>See UiButton example below
Pseudo-element stylingRaw CSS (non-layer).custom-scrollbar::-webkit-scrollbar { ... }

Computed class strings

When a component has variants, build the class string in JavaScript rather than duplicating utilities across conditional template expressions:

vue
<script setup lang="ts">
const props = defineProps<{
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
}>()

const classes = computed(() => {
  const base = 'font-medium rounded-lg transition-colors focus:outline-none focus:ring-2'

  const variants = {
    primary: 'bg-[#117ea7] text-white hover:bg-[#0e6b8f] focus:ring-[#117ea7]/50',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-400',
    danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
  }

  const sizes = {
    sm: 'px-3 py-1.5 text-sm',
    md: 'px-4 py-2 text-sm',
    lg: 'px-5 py-2.5 text-base',
  }

  return [
    base,
    variants[props.variant ?? 'primary'],
    sizes[props.size ?? 'md'],
    props.disabled && 'opacity-50 cursor-not-allowed',
  ]
})
</script>

<template>
  <button :class="classes" :disabled="disabled">
    <slot />
  </button>
</template>

Scoped Styles in Components

CPR components generally do not use <style scoped>. Before adding one, check if the need can be met by:

  1. Tailwind utilities in the template
  2. A computed class string
  3. A @layer components class in main.css

If none of those work -- typically because you need a complex CSS selector, a keyframe animation, or deep pseudo-element targeting -- then <style scoped> is acceptable:

vue
<style scoped>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

Keep scoped style blocks as small as possible. Do not duplicate what Tailwind already provides.

Decision Tree

Use this when deciding where to put a new style:

Is it a one-off style on a single element?
  YES --> Inline Tailwind utilities in the template
  NO  --> Is it a multi-variant component (size, color, state)?
            YES --> Computed class string in <script setup>
            NO  --> Is the same class combination used in 3+ places?
                      YES --> @layer components class in main.css
                      NO  --> Is it a pseudo-element or CSS feature Tailwind can't express?
                                YES --> Non-layer CSS in main.css (or <style scoped>)
                                NO  --> Inline Tailwind utilities

CPR - Clinical Patient Records